authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-18 13:10:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-18 13:10:32-07:00
loge95b57dc268471c882f734546fdcd92e9a833e45
tree09e429a3f404a1488f98bd839faa575d9c08813f
parent3bdaa6e10046945ccc08264d2d6f3b81775efcb3
signature Signed by PGP key B5690EEEBB952194

[codex] Rewrite client connection architecture (#2978)

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: Test60 - name: Test
61 run: vp run test61 run: vp run test
6262
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 Analysis64 name: Mobile Native Static Analysis
111 runs-on: blacksmith-12vcpu-macos-2665 runs-on: blacksmith-12vcpu-macos-26
apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts created+297
...@@ -0,0 +1,297 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform";
4import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as PlatformError from "effect/PlatformError";
10import * as Ref from "effect/Ref";
11import * as Schema from "effect/Schema";
12
13import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
14import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts";
15import * as DesktopConfig from "./DesktopConfig.ts";
16import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts";
17import * as DesktopEnvironment from "./DesktopEnvironment.ts";
18
19const textDecoder = new TextDecoder();
20const textEncoder = new TextEncoder();
21const decodeConnectionCatalog = Schema.decodeEffect(
22 Schema.fromJsonString(ConnectionCatalogDocument),
23);
24
25function 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
46function 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
84const 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
96describe("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 @@
1import {
2 BearerConnectionCredential,
3 BearerConnectionProfile,
4 BearerConnectionTarget,
5 RelayConnectionTarget,
6 SshConnectionProfile,
7 SshConnectionTarget,
8} from "@t3tools/client-runtime/connection";
9import {
10 ConnectionCatalogDocument as RuntimeConnectionCatalogDocument,
11 type ConnectionCatalogDocument as RuntimeConnectionCatalogDocumentType,
12} from "@t3tools/client-runtime/platform";
13import type { PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
14import { fromLenientJson } from "@t3tools/shared/schemaJson";
15import * as Context from "effect/Context";
16import * as Crypto from "effect/Crypto";
17import * as Data from "effect/Data";
18import * as Effect from "effect/Effect";
19import * as Encoding from "effect/Encoding";
20import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";
22import * as Option from "effect/Option";
23import * as Path from "effect/Path";
24import * as PlatformError from "effect/PlatformError";
25import * as Schema from "effect/Schema";
26
27import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
28import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts";
29import * as DesktopEnvironment from "./DesktopEnvironment.ts";
30
31const EncryptedConnectionCatalogDocument = Schema.Struct({
32 version: Schema.Literal(1),
33 encryptedCatalog: Schema.String,
34});
35type EncryptedConnectionCatalogDocument = typeof EncryptedConnectionCatalogDocument.Type;
36
37const EncryptedConnectionCatalogDocumentJson = fromLenientJson(EncryptedConnectionCatalogDocument);
38const decodeEncryptedConnectionCatalogDocumentJson = Schema.decodeEffect(
39 EncryptedConnectionCatalogDocumentJson,
40);
41const encodeEncryptedConnectionCatalogDocumentJson = Schema.encodeEffect(
42 EncryptedConnectionCatalogDocumentJson,
43);
44const RuntimeConnectionCatalogDocumentJson = Schema.fromJsonString(
45 RuntimeConnectionCatalogDocument,
46);
47const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect(
48 RuntimeConnectionCatalogDocumentJson,
49);
50
51export 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
61export 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
71export 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
81export 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
91export 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
111export class DesktopConnectionCatalogStore extends Context.Service<
112 DesktopConnectionCatalogStore,
113 DesktopConnectionCatalogStoreShape
114>()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {}
115
116function 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
124const 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
142const 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
170function connectionId(prefix: "bearer" | "ssh", environmentId: string): string {
171 return `${prefix}:${environmentId}`;
172}
173
174const 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
253export 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 @@
1import * as Context from "effect/Context";1import * as Electron from "electron";
2import * as Data from "effect/Data";2
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
55import * as Context from "effect/Context";
6import * as Electron from "electron";6import * as Data from "effect/Data";
77
8export class ElectronSafeStorageAvailabilityError extends Data.TaggedError(8export 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";
11import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";11import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
12import {12import {
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";
19import {17import {
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"
5957
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);
6763
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";
20export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";20export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
21export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";21export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
22export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";22export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
23export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";23export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog";
24export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";24export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog";
25export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";25export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog";
26export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
27export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
28export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";26export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
29export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";27export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
30export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";28export 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 === undefined63 input.body === undefined
64 ? identity64 ? 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 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4
5import * as DesktopConnectionCatalogStore from "../../app/DesktopConnectionCatalogStore.ts";
6import * as IpcChannels from "../channels.ts";
7import { makeIpcMethod } from "../DesktopIpc.ts";
8
9export 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
19export 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
29export 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 @@
1import { EnvironmentId, PersistedSavedEnvironmentRecordSchema } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5
6import * as DesktopSavedEnvironments from "../../settings/DesktopSavedEnvironments.ts";
7import * as IpcChannels from "../channels.ts";
8import { makeIpcMethod } from "../DesktopIpc.ts";
9
10const SavedEnvironmentRegistryPayload = Schema.Array(PersistedSavedEnvironmentRecordSchema);
11const NonBlankString = Schema.String.check(
12 Schema.makeFilter((value) =>
13 value.trim().length > 0 ? undefined : "Expected a non-empty string",
14 ),
15);
16
17const SetSavedEnvironmentSecretInput = Schema.Struct({
18 environmentId: EnvironmentId,
19 secret: NonBlankString,
20});
21
22export 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
32export 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
42export 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
52export 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
68export 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 @@
1import {1import {
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";
8import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
9import {9import {
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";
29import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";29import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
30import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts";30import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts";
31import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts";31import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts";
32import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
32import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";33import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
33import * as DesktopAssets from "./app/DesktopAssets.ts";34import * as DesktopAssets from "./app/DesktopAssets.ts";
34import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";35import * 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 );
291291
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");
300300
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}
8484
85export 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
85export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError(95export 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}
94104
95export type DesktopSavedEnvironmentsGetSecretError =105export type DesktopSavedEnvironmentsGetSecretError =
106 | DesktopSavedEnvironmentsReadError
96 | DesktopSavedEnvironmentSecretDecodeError107 | DesktopSavedEnvironmentSecretDecodeError
97 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError108 | 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;
104115
105export interface DesktopSavedEnvironmentsShape {116export 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(
176function readRegistryDocument(190function 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 })));
303320
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";
1616
17import { LoadingScreen } from "../components/LoadingScreen";17import { LoadingScreen } from "../components/LoadingScreen";
1818
19import {19import { useWorkspaceState } from "../state/workspace";
20 useRemoteEnvironmentBootstrap,20import { useThreadOutboxDrain } from "../state/use-thread-outbox-drain";
21 useRemoteEnvironmentState,
22} from "../state/use-remote-environment-registry";
23import { RegistryContext } from "@effect/atom-react";21import { RegistryContext } from "@effect/atom-react";
24import { appAtomRegistry } from "../state/atom-registry";22import { appAtomRegistry } from "../state/atom-registry";
25import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";23import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";
...@@ -42,12 +40,13 @@ function AppNavigator() {...@@ -42,12 +40,13 @@ function AppNavigator() {
42}40}
4341
44function AppNavigatorContent() {42function 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();
5150
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 };
8382
84 if (isLoadingSavedConnection) {83 if (state.isLoadingConnections) {
85 return <LoadingScreen message="Loading remote workspace…" />;84 return <LoadingScreen message="Loading remote workspace…" />;
86 }85 }
8786
...@@ -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 @@
1import { CameraView, useCameraPermissions } from "expo-camera";1import { CameraView, useCameraPermissions } from "expo-camera";
2import { Stack, useLocalSearchParams, useRouter } from "expo-router";2import { Stack, useLocalSearchParams, useRouter } from "expo-router";
3import { AsyncResult } from "effect/unstable/reactivity";
3import { useCallback, useEffect, useState } from "react";4import { useCallback, useEffect, useState } from "react";
4import { Alert, ScrollView, View } from "react-native";5import { Alert, ScrollView, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";6import { 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);
113114
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";
2import { useState } from "react";2import { useState } from "react";
3import { Text as RNText, View } from "react-native";3import { Text as RNText, View } from "react-native";
44
5import { useProjects, useThreadShells } from "../state/entities";
6import { useWorkspaceState } from "../state/workspace";
5import { buildThreadRoutePath } from "../lib/routes";7import { buildThreadRoutePath } from "../lib/routes";
6import { useRemoteCatalog } from "../state/use-remote-catalog";8import { useSavedRemoteConnections } from "../state/use-remote-environment-registry";
7import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry";
8import { HomeScreen } from "../features/home/HomeScreen";9import { HomeScreen } from "../features/home/HomeScreen";
9import { useThemeColor } from "../lib/useThemeColor";10import { useThemeColor } from "../lib/useThemeColor";
1011
11/* ─── Route screen ───────────────────────────────────────────────────── */12/* ─── Route screen ───────────────────────────────────────────────────── */
1213
13export default function HomeRouteScreen() {14export 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("");
1821
...@@ -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 @@
1import { Stack, useLocalSearchParams } from "expo-router";1import { Stack, useLocalSearchParams } from "expo-router";
2import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime";2import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects";
33
4import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen";4import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen";
55
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";
88
9import { AppText as Text } from "../../components/AppText";9import { AppText as Text } from "../../components/AppText";
10import { ProjectFavicon } from "../../components/ProjectFavicon";10import { ProjectFavicon } from "../../components/ProjectFavicon";
11import { useProjects, useThreadShells } from "../../state/entities";
12import type { WorkspaceState } from "../../state/workspaceModel";
13import { useWorkspaceState } from "../../state/workspace";
11import { groupProjectsByRepository } from "../../lib/repositoryGroups";14import { groupProjectsByRepository } from "../../lib/repositoryGroups";
12import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog";
13import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
1415
15function deriveProjectEmptyState(catalogState: RemoteCatalogState): {16function 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 }
2728
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 }
3536
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}
6470
65export default function NewTaskRoute() {71export 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 <ProjectFavicon192 <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 @@
1import { useAuth } from "@clerk/expo";1import { useAuth } from "@clerk/expo";
2import { Stack, useRouter } from "expo-router";2import { Stack, useRouter } from "expo-router";
3import { SymbolView } from "expo-symbols";3import { SymbolView } from "expo-symbols";
4import {
5 connectionStatusText,
6 type EnvironmentConnectionPhase,
7} from "@t3tools/client-runtime/connection";
4import type { EnvironmentId } from "@t3tools/contracts";8import type { EnvironmentId } from "@t3tools/contracts";
5import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";9import { useCallback, useState } from "react";
6import * as Effect from "effect/Effect";10import {
7import { useCallback, useMemo, useState } from "react";11 ActivityIndicator,
8import { 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";
9import { useSafeAreaInsets } from "react-native-safe-area-context";19import { useSafeAreaInsets } from "react-native-safe-area-context";
1020
11import { AppText as Text } from "../../components/AppText";21import { AppText as Text } from "../../components/AppText";
12import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment";
13import {22import {
14 hasCloudPublicConfig,23 type RelayEnvironmentView,
15 resolveRelayClerkTokenOptions,24 useConnectionController,
16} from "../../features/cloud/publicConfig";25} from "../../features/connection/useConnectionController";
17import {26import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
18 useManagedRelayEnvironments,27import { availableCloudEnvironmentPresentation } from "../../features/cloud/cloudEnvironmentPresentation";
19 useManagedRelayEnvironmentStatus,
20} from "../../features/cloud/managedRelayState";
21import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";28import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";
29import { ConnectionStatusDot } from "../../features/connection/ConnectionStatusDot";
30import { splitEnvironmentSections } from "../../features/connection/environmentSections";
22import { cn } from "../../lib/cn";31import { cn } from "../../lib/cn";
23import { mobileRuntime } from "../../lib/runtime";32import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
24import { useThemeColor } from "../../lib/useThemeColor";33import { useThemeColor } from "../../lib/useThemeColor";
25import {34import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
26 connectSavedEnvironment,35import { useRemoteConnections } from "../../state/use-remote-environment-registry";
27 useRemoteConnections,
28 useRemoteEnvironmentState,
29} from "../../state/use-remote-environment-registry";
3036
31export default function SettingsEnvironmentsRouteScreen() {37export 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");
4353
...@@ -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 <View85 <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 )}
110120
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}
116131
117function ConfiguredCloudEnvironmentRows() {132function 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 );
132143
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 );
158148
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;
160159
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 <Pressable164 <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>
178179
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 <CloudEnvironmentRow183 <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 environments214 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}
217233
234function 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
218function CloudEnvironmentRow(props: {263function 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 === null275 });
230 ? (statusState.error ?? (statusState.isPending ? "Checking status..." : "Status unavailable"))
231 : status.status === "online"
232 ? "Online"
233 : (status.error ?? "Offline");
234276
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
297function 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 <View353 <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 <Pressable426 <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
437function 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";
8import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native";8import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";9import { useSafeAreaInsets } from "react-native-safe-area-context";
1010
11import {
12 isAtomCommandInterrupted,
13 reportAtomCommandResult,
14 settleAsyncResult,
15 settlePromise,
16 squashAtomCommandFailure,
17} from "@t3tools/client-runtime/state/runtime";
11import { AppText as Text } from "../../components/AppText";18import { AppText as Text } from "../../components/AppText";
12import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences";19import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences";
13import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions";20import { 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";
21import { mobileRuntime } from "../../lib/runtime";28import { runtime } from "../../lib/runtime";
22import { loadPreferences } from "../../lib/storage";29import { loadPreferences } from "../../lib/storage";
23import { useThemeColor } from "../../lib/useThemeColor";30import { useThemeColor } from "../../lib/useThemeColor";
24import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";31import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
2532
26type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";33type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
27type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";34type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
...@@ -32,7 +39,7 @@ export default function SettingsRouteScreen() {...@@ -32,7 +39,7 @@ export default function SettingsRouteScreen() {
3239
33function LocalSettingsRouteScreen() {40function 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;
3744
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");
7683
...@@ -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 }, []);
93105
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]);
116129
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 }, []);
162180
163 const promptSignIn = useCallback(() => {181 const promptSignIn = useCallback(() => {
...@@ -178,36 +196,51 @@ function ConfiguredSettingsRouteScreen() {...@@ -178,36 +196,51 @@ function ConfiguredSettingsRouteScreen() {
178 }196 }
179197
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 }
188214
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]);
212245
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 <Text437 </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 <SymbolView449 <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 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "expo-symbols";
2import { useState } from "react";2import { useState } from "react";
3import { Image, View } from "react-native";3import { Image, View } from "react-native";
4import type { EnvironmentId } from "@t3tools/contracts";
4import { useThemeColor } from "../lib/useThemeColor";5import { useThemeColor } from "../lib/useThemeColor";
6import { useAssetUrl } from "../state/assets";
57
6/* ─── Favicon cache (matches web pattern) ────────────────────────────── */8/* ─── Favicon cache (matches web pattern) ────────────────────────────── */
7const loadedFaviconUrls = new Set<string>();9const loadedFaviconUrls = new Set<string>();
810
9/* ─── Component ──────────────────────────────────────────────────────── */11/* ─── Component ──────────────────────────────────────────────────────── */
10export function ProjectFavicon(props: {12export 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}
1935
20 const faviconUrl =36function ProjectFaviconImage(props: {
21 props.httpBaseUrl && props.workspaceRoot37 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");
2442
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 );
2846
29 const showImage = faviconUrl && status === "loaded";47 const showImage = props.faviconUrl !== null && status === "loaded";
3048
31 return (49 return (
32 <View50 <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}
4467
45 {/* Favicon image (hidden until loaded) */}68 {/* Favicon image (hidden until loaded) */}
46 {faviconUrl ? (69 {props.faviconUrl ? (
47 <Image70 <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 @@
1import {
2 ConnectionCatalogDocument,
3 type ConnectionCatalogDocument as ConnectionCatalogDocumentType,
4 EMPTY_CONNECTION_CATALOG_DOCUMENT,
5} from "@t3tools/client-runtime/platform";
6import { ConnectionTransientError } from "@t3tools/client-runtime/connection";
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import * as Ref from "effect/Ref";
10import * as Schema from "effect/Schema";
11import * as Semaphore from "effect/Semaphore";
12
13import { migrateLegacyConnectionCatalog } from "./migration";
14
15export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1";
16export const LEGACY_CONNECTIONS_KEY = "t3code.connections";
17
18function 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
25const 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
35const 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
44interface CatalogStore {
45 readonly read: Effect.Effect<ConnectionCatalogDocumentType, ConnectionTransientError>;
46 readonly update: (
47 transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType,
48 ) => Effect.Effect<void, ConnectionTransientError>;
49}
50
51export 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
57export 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 @@
1import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections";
2
3import { connectionAtomRuntime } from "./runtime";
4
5export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime);
apps/mobile/src/connection/migration.test.ts created+78
...@@ -0,0 +1,78 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4
5import { migrateLegacyConnectionCatalog } from "./migration";
6
7describe("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 @@
1import {
2 BearerConnectionCredential,
3 BearerConnectionProfile,
4 BearerConnectionRegistration,
5 RelayConnectionRegistration,
6 RelayConnectionTarget,
7 BearerConnectionTarget,
8} from "@t3tools/client-runtime/connection";
9import {
10 type ConnectionCatalogDocument,
11 EMPTY_CONNECTION_CATALOG_DOCUMENT,
12 registerConnectionInCatalog,
13} from "@t3tools/client-runtime/platform";
14import { EnvironmentId } from "@t3tools/contracts";
15import * as Effect from "effect/Effect";
16import * as Schema from "effect/Schema";
17
18const 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
31const LegacyConnectionDocument = Schema.Struct({
32 connections: Schema.optionalKey(Schema.Array(LegacySavedRemoteConnection)),
33});
34const decodeLegacyConnectionDocument = Schema.decodeUnknownEffect(LegacyConnectionDocument);
35
36export class LegacyConnectionMigrationError extends Schema.TaggedErrorClass<LegacyConnectionMigrationError>()(
37 "LegacyConnectionMigrationError",
38 {
39 message: Schema.String,
40 },
41) {}
42
43function isRelayManaged(connection: typeof LegacySavedRemoteConnection.Type): boolean {
44 return connection.relayManaged === true || connection.authenticationMethod === "dpop";
45}
46
47function 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
90export 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 @@
1import { ConnectionOnboarding } from "@t3tools/client-runtime/connection";
2import {
3 createAtomCommandScheduler,
4 createRuntimeCommand,
5} from "@t3tools/client-runtime/state/runtime";
6import type { EnvironmentId } from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8
9import { connectionAtomRuntime } from "./runtime";
10
11const onboardingScheduler = createAtomCommandScheduler();
12
13export 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
23export 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 @@
1import {
2 ClientPresentation,
3 CloudSession,
4 EnvironmentOwnedDataCleanup,
5 PlatformConnectionSource,
6 RelayDeviceIdentity,
7 SshEnvironmentGateway,
8} from "@t3tools/client-runtime/platform";
9import {
10 ConnectionBlockedError,
11 ConnectionTransientError,
12 ConnectionWakeups,
13 Connectivity,
14} from "@t3tools/client-runtime/connection";
15import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
16import { AuthStandardClientScopes } from "@t3tools/contracts";
17import * as Context from "effect/Context";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Queue from "effect/Queue";
22import * as Stream from "effect/Stream";
23import * as Network from "expo-network";
24import { AppState } from "react-native";
25
26import { authClientMetadata } from "../lib/authClientMetadata";
27import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage";
28import { appAtomRegistry } from "../state/atom-registry";
29import { clearThreadOutboxEnvironment } from "../state/thread-outbox";
30import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
31import { connectionStorageLayer } from "./storage";
32
33function 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
43const 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
68const 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
91const 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
165const platformConnectionSourceLayer = Layer.succeed(
166 PlatformConnectionSource,
167 PlatformConnectionSource.of({
168 registrations: Stream.empty,
169 }),
170);
171
172const 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
193export 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 @@
1import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection";
2import * as Layer from "effect/Layer";
3import { Atom } from "effect/unstable/reactivity";
4
5import { runtimeContextLayer } from "../lib/runtime";
6import { connectionPlatformLayer } from "./platform";
7
8const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
9 Layer.provide(runtimeContextLayer),
10);
11
12export const connectionLayer = clientConnectionLayer.pipe(
13 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),
14);
15
16export const connectionAtomRuntime = Atom.runtime(connectionLayer);
apps/mobile/src/connection/storage.test.ts created+84
...@@ -0,0 +1,84 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3
4import {
5 CONNECTION_CATALOG_KEY,
6 LEGACY_CONNECTIONS_KEY,
7 makeCatalogStore,
8 type SecureCatalogStorage,
9} from "./catalog-store";
10
11function 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
29describe("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 @@
1import {
2 ConnectionPersistenceError,
3 ConnectionRegistrationStore,
4 ConnectionTargetStore,
5 EnvironmentCacheStore,
6 registerConnectionInCatalog,
7 removeConnectionFromCatalog,
8 removeCatalogValue,
9 replaceCatalogValue,
10} from "@t3tools/client-runtime/platform";
11import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization";
12import {
13 ConnectionCredentialStore,
14 ConnectionProfileStore,
15 ConnectionTransientError,
16} from "@t3tools/client-runtime/connection";
17import {
18 EnvironmentId,
19 OrchestrationThread,
20 OrchestrationShellSnapshot,
21 ThreadId,
22} from "@t3tools/contracts";
23import * as Context from "effect/Context";
24import * as Effect from "effect/Effect";
25import * as Layer from "effect/Layer";
26import * as Option from "effect/Option";
27import * as Schema from "effect/Schema";
28import * as SecureStore from "expo-secure-store";
29
30import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store";
31
32const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
33const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots";
34const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
35const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
36const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots";
37
38const StoredShellSnapshot = Schema.Struct({
39 schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
40 environmentId: EnvironmentId,
41 snapshot: OrchestrationShellSnapshot,
42});
43
44const StoredThreadSnapshot = Schema.Struct({
45 schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),
46 environmentId: EnvironmentId,
47 threadId: ThreadId,
48 thread: OrchestrationThread,
49});
50
51const LegacyStoredShellSnapshot = Schema.Struct({
52 schemaVersion: Schema.Literal(1),
53 environmentId: EnvironmentId,
54 snapshotReceivedAt: Schema.String,
55 snapshot: OrchestrationShellSnapshot,
56});
57
58function 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
65function 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
81function threadSnapshotFileName(threadId: ThreadId): string {
82 return `${encodeURIComponent(threadId)}.json`;
83}
84
85const 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
108const 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
120function 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
130const 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
148function shellSnapshotFileName(environmentId: EnvironmentId): string {
149 return `${encodeURIComponent(environmentId)}.json`;
150}
151
152const 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
170const shellSnapshotFile = (
171 environmentId: EnvironmentId,
172 operation: "load-shell" | "save-shell" | "clear-environment",
173) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY);
174
175const legacyShellSnapshotFile = (
176 environmentId: EnvironmentId,
177 operation: "load-shell" | "clear-environment",
178) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY);
179
180export 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";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
5import { ManagedRelayClient } from "@t3tools/client-runtime";5import { ManagedRelayClient } from "@t3tools/client-runtime/relay";
6import * as Layer from "effect/Layer";
6import { HttpClient } from "effect/unstable/http";7import { HttpClient } from "effect/unstable/http";
78
8import type { SavedRemoteConnection } from "../../lib/connection";9import 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};
3536
36const runWithHttpClient = <A, E>(37const 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 );
4844
49describe("liveActivityPreferences", () => {45describe("liveActivityPreferences", () => {
50 beforeEach(() => {46 beforeEach(() => {
51 vi.clearAllMocks();47 vi.clearAllMocks();
52 });48 });
5349
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 });
7057
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 });
8774
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 );
9691
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 );
10197
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 };
107103
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
115111 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 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import { HttpClient } from "effect/unstable/http";2import { HttpClient } from "effect/unstable/http";
3import { ManagedRelayClient } from "@t3tools/client-runtime";3import { ManagedRelayClient } from "@t3tools/client-runtime/relay";
44
5import type { SavedRemoteConnection } from "../../lib/connection";5import type { SavedRemoteConnection } from "../../lib/connection";
6import { savePreferencesPatch } from "../../lib/storage";6import { savePreferencesPatch } from "../../lib/storage";
apps/mobile/src/features/agent-awareness/registrationPayload.ts+2-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";1import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";
22
3import type { MobilePreferences } from "../../lib/storage";3import type { Preferences } from "../../lib/storage";
44
5export function makeRelayDeviceRegistrationRequest(input: {5export 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";
6import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
7import Constants from "expo-constants";7import Constants from "expo-constants";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Exit from "effect/Exit";
9import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
10import { FetchHttpClient } from "effect/unstable/http";11import { FetchHttpClient } from "effect/unstable/http";
11import type { ManagedRelayClient } from "@t3tools/client-runtime";12import { type ManagedRelayClient } from "@t3tools/client-runtime/relay";
1213
13import type { EnvironmentId } from "@t3tools/contracts";14import type { EnvironmentId } from "@t3tools/contracts";
14import { verifyDpopProof } from "@t3tools/shared/dpop";15import { verifyDpopProof } from "@t3tools/shared/dpop";
15import type { SavedRemoteConnection } from "../../lib/connection";16import type { SavedRemoteConnection } from "../../lib/connection";
16import { mobileCryptoLayer } from "../cloud/dpop";17import { cryptoLayer } from "../cloud/dpop";
17import { mobileManagedRelayClientLayer } from "../cloud/managedRelayLayer";18import { managedRelayClientLayer } from "../cloud/managedRelayLayer";
18import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";19import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
19import {20import {
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>());
33const widgetMocks = vi.hoisted(() => ({34const widgetMocks = vi.hoisted(() => ({
34 getInstances: vi.fn(() => []),35 getInstances: vi.fn(() => []),
35}));36}));
37const backgroundRuntime = vi.hoisted(() => ({
38 pending: [] as Array<{
39 readonly operation: unknown;
40 readonly resolve: (exit: Exit.Exit<unknown, unknown>) => void;
41 }>,
42}));
3643
37vi.mock("expo-constants", () => ({44vi.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}));
96103
97vi.mock("../../lib/runtime", () => ({104vi.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}));
111112
...@@ -138,34 +139,40 @@ function savedConnection(): SavedRemoteConnection {...@@ -138,34 +139,40 @@ function savedConnection(): SavedRemoteConnection {
138 };139 };
139}140}
140141
141const runRegistrationEffect = <A, E>(effect: Effect.Effect<A, E, ManagedRelayClient>): Promise<A> =>142const 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(146const 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();
151152 if (!pending) {
152async 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}
163169
164describe("makeRelayDeviceRegistrationRequest", () => {170describe("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 });
245252
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 };
253260
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);
260264
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 });
264269
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 };
271278
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 );
276284
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"));
287297
288 await runRegistrationEffect(refreshActiveLiveActivityRemoteRegistration());298 return Effect.gen(function* () {
299 yield* refreshActiveLiveActivityRemoteRegistration();
289300
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 );
295308
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();
300314
301 await runRegistrationEffect(refreshAgentAwarenessRegistration());315 yield* refreshAgentAwarenessRegistration();
302316
303 expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);317 expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);
318 }).pipe(Effect.provide(relayTestLayer));
304 });319 });
305320
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"));
331346
332 return Effect.gen(function* () {347 return Effect.gen(function* () {
333 yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2));348 yield* runBackgroundOperations();
334349
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 });
362435
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 });
369442
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);
400473
401 await new Promise((resolve) => setTimeout(resolve, 0));474 return Effect.gen(function* () {
402475 yield* runBackgroundOperations();
403 expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);476 expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);
477 }).pipe(Effect.provide(relayTestLayer));
404 });478 });
405479
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();
437511
438 unregisterAgentAwarenessConnection(savedConnection().environmentId);512 unregisterAgentAwarenessConnection(savedConnection().environmentId);
439513
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";
11import { ManagedRelayClient } from "@t3tools/client-runtime";11import { findErrorTraceId } from "@t3tools/client-runtime/errors";
12import { ManagedRelayClient } from "@t3tools/client-runtime/relay";
13import {
14 isAtomCommandInterrupted,
15 settleAsyncResult,
16 squashAtomCommandFailure,
17} from "@t3tools/client-runtime/state/runtime";
1218
13import type { SavedRemoteConnection } from "../../lib/connection";19import type { SavedRemoteConnection } from "../../lib/connection";
14import { mobileRuntime } from "../../lib/runtime";20import { runtime } from "../../lib/runtime";
15import {21import {
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;
29let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null;35let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null;
30let relayTokenProvider: (() => Promise<string | null>) | null = null;36let relayTokenProvider: (() => Promise<string | null>) | null = null;
31let relayTokenProviderIdentity: string | null = null;37let relayTokenProviderIdentity: string | null = null;
38let deviceRegistrationGeneration = 0;
39let activeDeviceRegistration: {
40 readonly input: DeviceRegistrationInput;
41 operation: Promise<void>;
42} | null = null;
43let pendingDeviceRegistration: {
44 readonly input: DeviceRegistrationInput;
45 readonly context: string;
46} | null = null;
47
48interface DeviceRegistrationInput {
49 readonly pushToStartToken?: string;
50 readonly observedPushToken?: string;
51}
3252
33export function normalizeAgentAwarenessRelayBaseUrl(53export 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}
95120
96function iosMajorVersion(): number {121function iosMajorVersion(): number {
...@@ -149,20 +174,41 @@ const relayToken = Effect.gen(function* () {...@@ -149,20 +174,41 @@ const relayToken = Effect.gen(function* () {
149174
150function registerDeviceWithRelay(175function 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 }
160200
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}
168214
...@@ -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}
221268
222function logRegistrationDebug(context: string, details?: unknown): void {269function 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
288function 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
302function 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
312function 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}
237344
238function registerDevice(input?: {345function 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
370function 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 }
246379
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}
278417
279function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void {418function 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}
285421
286function ensurePushToStartListener(): void {422function ensurePushToStartListener(): void {
...@@ -303,8 +439,8 @@ function ensurePushTokenListener(): void {...@@ -303,8 +439,8 @@ function ensurePushTokenListener(): void {
303439
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}
376515
377export function unregisterAgentAwarenessDeviceForCurrentUser(516export function unregisterAgentAwarenessDeviceForCurrentUser(
apps/mobile/src/features/cloud/CloudAuthProvider.test.ts created+60
...@@ -0,0 +1,60 @@
1import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { appAtomRegistry } from "../../state/atom-registry";
5import { activateCloudRelayAccount, deactivateCloudRelayAccount } from "./CloudAuthProvider";
6import { setAgentAwarenessRelayTokenProvider } from "../agent-awareness/remoteRegistration";
7
8vi.mock("@clerk/expo", () => ({
9 ClerkProvider: vi.fn(),
10 useAuth: vi.fn(),
11}));
12
13vi.mock("@clerk/expo/token-cache", () => ({
14 tokenCache: {},
15}));
16
17vi.mock("../../lib/runtime", () => ({
18 runtime: {
19 runPromiseExit: vi.fn(),
20 },
21}));
22
23vi.mock("../../connection/catalog", () => ({
24 environmentCatalog: {
25 removeRelayEnvironments: {},
26 },
27}));
28
29vi.mock("./publicConfig", () => ({
30 resolveCloudPublicConfig: vi.fn(() => ({
31 clerk: { publishableKey: null },
32 relay: { url: null },
33 })),
34 resolveRelayClerkTokenOptions: vi.fn(),
35}));
36
37vi.mock("../agent-awareness/remoteRegistration", () => ({
38 setAgentAwarenessRelayTokenProvider: vi.fn(),
39 unregisterAgentAwarenessDeviceForCurrentUser: vi.fn(),
40}));
41
42afterEach(() => {
43 deactivateCloudRelayAccount();
44 vi.clearAllMocks();
45});
46
47describe("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 @@
1import { ClerkProvider, useAuth } from "@clerk/expo";1import { ClerkProvider, useAuth } from "@clerk/expo";
2import { tokenCache } from "@clerk/expo/token-cache";2import { tokenCache } from "@clerk/expo/token-cache";
3import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime";3import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay";
4import {
5 reportAtomCommandResult,
6 settleAsyncResult,
7 settlePromise,
8} from "@t3tools/client-runtime/state/runtime";
9import * as Effect from "effect/Effect";
4import { type ReactNode, useEffect, useRef } from "react";10import { type ReactNode, useEffect, useRef } from "react";
511
6import { mobileRuntime } from "../../lib/runtime";12import { environmentCatalog } from "../../connection/catalog";
13import { runtime } from "../../lib/runtime";
7import { appAtomRegistry } from "../../state/atom-registry";14import { appAtomRegistry } from "../../state/atom-registry";
15import { useAtomCommand } from "../../state/use-atom-command";
8import {16import {
9 setAgentAwarenessRelayTokenProvider,17 setAgentAwarenessRelayTokenProvider,
10 unregisterAgentAwarenessDeviceForCurrentUser,18 unregisterAgentAwarenessDeviceForCurrentUser,
11} from "../agent-awareness/remoteRegistration";19} from "../agent-awareness/remoteRegistration";
12import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";20import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
1321
22function resetManagedRelayTokenCache() {
23 return settleAsyncResult(() =>
24 runtime.runPromiseExit(
25 ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),
26 ),
27 );
28}
29
30export function deactivateCloudRelayAccount(): void {
31 setAgentAwarenessRelayTokenProvider(null);
32 setManagedRelaySession(appAtomRegistry, null);
33}
34
35export 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
14function CloudAuthBridge(props: { readonly children: ReactNode }) {46function 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);
2058
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 mobileRuntime102 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 }
37107
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]);
55142
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 }) {
72158
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]);
79164
apps/mobile/src/features/cloud/cloudDebugLog.ts created+18
...@@ -0,0 +1,18 @@
1export 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
9export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
3import { describe, expect, it } from "vite-plus/test";
4
5import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation";
6
7function 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
26describe("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 @@
1import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
2import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
3
4export interface AvailableCloudEnvironmentPresentation {
5 readonly connectionError: string | null;
6 readonly connectionErrorTraceId: string | null;
7 readonly connectionState: EnvironmentConnectionPhase;
8 readonly statusText: string;
9}
10
11export 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";
1717
18vi.mock("expo-crypto", () => ({18vi.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 );
8080
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", () => {
8686
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 );
9191
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);
9797
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 );
101101
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 );
140140
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}
7272
73export const mobileCryptoLayer = Layer.succeed(73export 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";12import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
13import { HttpClient } from "effect/unstable/http";13import { HttpClient } from "effect/unstable/http";
1414
15import {15import {
...@@ -55,6 +55,8 @@ const savedConnection = {...@@ -55,6 +55,8 @@ const savedConnection = {
55 bearerToken: "local-bearer",55 bearerToken: "local-bearer",
56};56};
5757
58const stableClerkToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzEyMyJ9.test";
59
58const createProofMock = vi.fn(60const 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);
354356
355 yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }));357 yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: stableClerkToken }));
356358
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", () => {
425427
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";
25import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";
26import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
27import { findErrorTraceId } from "@t3tools/client-runtime/errors";
26import {28import {
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";
33import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
3334
34import { mobileAuthClientMetadata } from "../../lib/authClientMetadata";35import { authClientMetadata } from "../../lib/authClientMetadata";
35import type { SavedRemoteConnection } from "../../lib/connection";36import type { SavedRemoteConnection } from "../../lib/connection";
36import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage";37import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage";
37import { resolveCloudPublicConfig } from "./publicConfig";38import { resolveCloudPublicConfig } from "./publicConfig";
...@@ -56,6 +57,7 @@ function readRelayUrl(): string | null {...@@ -56,6 +57,7 @@ function readRelayUrl(): string | null {
56export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{57export 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}> {}
6062
61export interface CloudEnvironmentRecordWithStatus {63export 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}
6668
67const isRelayProtectedError = Schema.is(RelayProtectedError);
68const isEnvironmentCloudApiError = Schema.is(69const 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 =
82function cloudEnvironmentLinkError(message: string) {83function 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: environmentError88 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}
154157
155function decodedRelayClientError(message: string) {158function 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}
165169
166function 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
176function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null {170function 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}
464458
465function connectRelayManagedEnvironment(input: {459const 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
468const 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* relayClient479 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);
554551
555export function connectCloudEnvironment(input: {552export 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 @@
1import {1import {
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";
6import { RelayMobileClientId } from "@t3tools/contracts/relay";6import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import * as Crypto from "effect/Crypto";7import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
1010
11import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop";11import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop";
12import { managedRelayAccessTokenStore } from "./managedRelayTokenStore";
1213
13const mobileRelayDpopSignerLayer = Layer.effect(14const 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);
3840
39export const mobileManagedRelayClientLayer = (relayUrl: string) =>41export 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";
7import type {7import type {
8 RelayClientEnvironmentRecord,8 RelayClientEnvironmentRecord,
9 RelayEnvironmentStatusResponse,9 RelayEnvironmentStatusResponse,
10} from "@t3tools/contracts/relay";10} from "@t3tools/contracts/relay";
11import { AsyncResult, Atom } from "effect/unstable/reactivity";11import { AsyncResult, Atom } from "effect/unstable/reactivity";
12import { useCallback } from "react";12import { useCallback, useEffect } from "react";
1313
14import { mobileRuntimeContextLayer } from "../../lib/runtime";14import { runtimeContextLayer } from "../../lib/runtime";
15import { appAtomRegistry } from "../../state/atom-registry";15import { appAtomRegistry } from "../../state/atom-registry";
16import { cloudDebugLog } from "./cloudDebugLog";
1617
17const managedRelayAtomRuntime = Atom.runtime(mobileRuntimeContextLayer);18const managedRelayAtomRuntime = Atom.runtime(runtimeContextLayer);
1819
19export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime);20export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime, {
21 onQueryEvent: (event) =>
22 cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }),
23});
2024
21const EMPTY_ENVIRONMENTS_ATOM = Atom.make(25const 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]);
4154
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]);
6487
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 @@
1import { expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { vi } from "vite-plus/test";
4
5const secureStore = vi.hoisted(() => new Map<string, string>());
6
7vi.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
19import { managedRelayAccessTokenStore } from "./managedRelayTokenStore";
20
21it.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
44it.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 @@
1import {
2 type ManagedRelayAccessTokenCacheEntry,
3 type ManagedRelayAccessTokenStore,
4} from "@t3tools/client-runtime/relay";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schema from "effect/Schema";
8import * as SecureStore from "expo-secure-store";
9
10const MANAGED_RELAY_TOKEN_CACHE_KEY = "t3code.cloud.relay-access-tokens";
11const MANAGED_RELAY_TOKEN_CACHE_VERSION = 1;
12
13const 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
25const ManagedRelayAccessTokenCacheSchema = Schema.fromJsonString(
26 Schema.Struct({
27 version: Schema.Literal(MANAGED_RELAY_TOKEN_CACHE_VERSION),
28 entries: Schema.Array(ManagedRelayAccessTokenCacheEntrySchema),
29 }),
30);
31
32const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect(
33 ManagedRelayAccessTokenCacheSchema,
34);
35const encodeManagedRelayAccessTokenCache = Schema.encodeEffect(ManagedRelayAccessTokenCacheSchema);
36
37export class ManagedRelayTokenStoreError extends Data.TaggedError("ManagedRelayTokenStoreError")<{
38 readonly message: string;
39 readonly cause: unknown;
40}> {}
41
42const storeError =
43 (message: string) =>
44 (cause: unknown): ManagedRelayTokenStoreError =>
45 new ManagedRelayTokenStoreError({ message, cause });
46
47function 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
57const 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
71const 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
85const clearManagedRelayAccessTokens = Effect.tryPromise({
86 try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY),
87 catch: storeError("Could not clear persisted relay access tokens."),
88});
89
90export 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 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
22
3import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig";3import { hasTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig";
44
5vi.mock("expo-constants", () => ({5vi.mock("expo-constants", () => ({
6 default: {6 default: {
...@@ -94,9 +94,9 @@ describe("resolveCloudPublicConfig", () => {...@@ -94,9 +94,9 @@ describe("resolveCloudPublicConfig", () => {
94 });94 });
9595
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};
7272
73type MobileTracingPublicConfig = Omit<CloudPublicConfig, "observability"> & {73type TracingPublicConfig = Omit<CloudPublicConfig, "observability"> & {
74 readonly observability: Configured<CloudPublicConfig["observability"]>;74 readonly observability: Configured<CloudPublicConfig["observability"]>;
75};75};
7676
77export function hasMobileTracingPublicConfig(77export 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 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "expo-symbols";
2import { connectionStatusText } from "@t3tools/client-runtime/connection";
3import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
5import * as Cause from "effect/Cause";
6import { AsyncResult } from "effect/unstable/reactivity";
3import { useCallback, useState } from "react";7import { useCallback, useState } from "react";
4import { Pressable, View } from "react-native";8import { Alert, Pressable, View } from "react-native";
5import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";9import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
6import { useThemeColor } from "../../lib/useThemeColor";10import { useThemeColor } from "../../lib/useThemeColor";
711
8import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";12import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
13import { cn } from "../../lib/cn";
14import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
9import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";15import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
10import { ConnectionStatusDot } from "./ConnectionStatusDot";16import { ConnectionStatusDot } from "./ConnectionStatusDot";
1117
12function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {18function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
13 if (environment.connectionError) {19 return connectionStatusText({
14 return null;20 phase: environment.connectionState,
15 }21 error: environment.connectionError,
1622 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}
3025
31export function ConnectionEnvironmentRow(props: {26export 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);
5045 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]);
5865
59 return (66 return (
...@@ -64,10 +71,7 @@ export function ConnectionEnvironmentRow(props: {...@@ -64,10 +71,7 @@ export function ConnectionEnvironmentRow(props: {
64 >71 >
65 <ConnectionStatusDot72 <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 />
7377
...@@ -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 <Text89 <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, {
1111
12import type { RemoteClientConnectionState } from "../../lib/connection";12import type { RemoteClientConnectionState } from "../../lib/connection";
1313
14function statusDotTone(state: RemoteClientConnectionState): {14export type ConnectionStatusDotState = RemoteClientConnectionState;
15
16function 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}
6471
65export function ConnectionStatusDot(props: {72export 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 @@
1import {
2 type EnvironmentConnectionPhase,
3 type EnvironmentConnectionPresentation,
4} from "@t3tools/client-runtime/connection";
5import { SymbolView } from "expo-symbols";
6import { ActivityIndicator, Pressable, View } from "react-native";
7
8import { AppText as Text } from "../../components/AppText";
9import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
10import { useThemeColor } from "../../lib/useThemeColor";
11
12function 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
29function 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
52export 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";
33
4export function connectionTone(state: RemoteClientConnectionState): StatusTone {4export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
3import { describe, expect, it } from "vite-plus/test";
4import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
5import { splitEnvironmentSections } from "./environmentSections";
6
7function 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
24function 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
37describe("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 @@
1import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
2import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
3
4export interface EnvironmentSectionsInput {
5 readonly connectedEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
6 readonly cloudEnvironments: ReadonlyArray<RelayClientEnvironmentRecord> | null;
7}
8
9export interface EnvironmentSections {
10 readonly localEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
11 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
12 readonly availableCloudEnvironments: ReadonlyArray<RelayClientEnvironmentRecord>;
13}
14
15export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 RelayConnectionRegistration,
4 RelayConnectionTarget,
5} from "@t3tools/client-runtime/connection";
6import type { EnvironmentId } from "@t3tools/contracts";
7import type {
8 RelayClientEnvironmentRecord,
9 RelayEnvironmentStatusResponse,
10} from "@t3tools/contracts/relay";
11import * as Option from "effect/Option";
12import { useCallback, useMemo } from "react";
13
14import { environmentCatalog } from "../../connection/catalog";
15import {
16 connectPairingUrl as connectPairingUrlAtom,
17 updateBearerConnection,
18} from "../../connection/onboarding";
19import { useEnvironments } from "../../state/environments";
20import { relayEnvironmentDiscovery } from "../../state/relay";
21import { useAtomCommand } from "../../state/use-atom-command";
22import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel";
23
24export 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
32export 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 @@
1import type {1import {
2 EnvironmentScopedProjectShell,2 type EnvironmentProject,
3 EnvironmentScopedThreadShell,3 type EnvironmentThreadShell,
4 VcsStatusState,4} from "@t3tools/client-runtime/state/shell";
5} from "@t3tools/client-runtime";
6import { SymbolView } from "expo-symbols";5import { SymbolView } from "expo-symbols";
7import { useCallback, useMemo, useState } from "react";6import { useCallback, useMemo, useState } from "react";
8import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";7import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
8import { useSafeAreaInsets } from "react-native-safe-area-context";
9import * as Arr from "effect/Array";9import * as Arr from "effect/Array";
10import * as Order from "effect/Order";10import * as Order from "effect/Order";
11import { useThemeColor } from "../../lib/useThemeColor";11import { useThemeColor } from "../../lib/useThemeColor";
...@@ -13,29 +13,29 @@ import { useThemeColor } from "../../lib/useThemeColor";...@@ -13,29 +13,29 @@ import { useThemeColor } from "../../lib/useThemeColor";
13import { AppText as Text } from "../../components/AppText";13import { AppText as Text } from "../../components/AppText";
14import { EmptyState } from "../../components/EmptyState";14import { EmptyState } from "../../components/EmptyState";
15import { ProjectFavicon } from "../../components/ProjectFavicon";15import { ProjectFavicon } from "../../components/ProjectFavicon";
16import type { WorkspaceState } from "../../state/workspaceModel";
16import type { SavedRemoteConnection } from "../../lib/connection";17import type { SavedRemoteConnection } from "../../lib/connection";
17import { scopedProjectKey } from "../../lib/scopedEntities";18import { scopedProjectKey } from "../../lib/scopedEntities";
18import { relativeTime } from "../../lib/time";19import { relativeTime } from "../../lib/time";
19import type { RemoteCatalogState } from "../../state/use-remote-catalog";
20import { useVcsStatus } from "../../state/use-vcs-status";
21import { threadStatusTone } from "../threads/threadPresentation";20import { threadStatusTone } from "../threads/threadPresentation";
2221
23/* ─── Types ──────────────────────────────────────────────────────────── */22/* ─── Types ──────────────────────────────────────────────────────────── */
2423
25interface HomeScreenProps {24interface 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}
3434
35interface ProjectGroup {35interface 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}
4040
41const projectGroupActivityOrder = Order.mapInput(41const projectGroupActivityOrder = Order.mapInput(
...@@ -49,7 +49,7 @@ const projectGroupActivityOrder = Order.mapInput(...@@ -49,7 +49,7 @@ const projectGroupActivityOrder = Order.mapInput(
4949
50/* ─── Status indicator colors ────────────────────────────────────────── */50/* ─── Status indicator colors ────────────────────────────────────────── */
5151
52function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: string } {52function 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
67const COLLAPSED_THREAD_LIMIT = 6;67const COLLAPSED_THREAD_LIMIT = 6;
6868
69function deriveEmptyState(props: {69function 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 }
8181
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 }
8989
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 ───────────────────────────────────────────── */
128133
129function ProjectGroupLabel(props: {134function 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 <ProjectFavicon144 <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 <Text150 <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}
169171
170/* ─── Git summary line ──────────────────────────────────────────────── */
171
172function 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 ─────────────────────────────────────────────────────── */
186173
187function ThreadRow(props: {174function 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;
199186 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);
208189
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>
264245
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 <SymbolView249 <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: {
293269
294/* ─── Main screen ────────────────────────────────────────────────────── */270/* ─── Main screen ────────────────────────────────────────────────────── */
295271
272function 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
286function 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
296export function HomeScreen(props: HomeScreenProps) {324export 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");
299328
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) {
328357
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 });
358391
359 return (392 return (
360 <ScrollView393 <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 <EmptyState408 <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 @@
1import { expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { vi } from "vite-plus/test";
5
6import { remoteHttpClientLayer } from "@t3tools/client-runtime";
7import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
8
9import { makeMobileTracingLayer } from "./mobileTracing";
10
11vi.mock("expo-constants", () => ({
12 default: {
13 expoConfig: {
14 extra: {},
15 },
16 },
17}));
18
19it.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 @@
1import Constants from "expo-constants";
2import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing";
3
4import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig";
5
6export interface MobileTracingConfig {
7 readonly tracesUrl: string;
8 readonly tracesDataset: string;
9 readonly tracesToken: string;
10}
11
12export interface MobileTracingResource {
13 readonly serviceVersion?: string;
14 readonly appVariant: string;
15}
16
17export 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
26export 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
38export 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 @@
1import { expect, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { vi } from "vite-plus/test";
6
7import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
8import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
9
10import { makeTracingLayer } from "./tracing";
11
12vi.mock("expo-constants", () => ({
13 default: {
14 expoConfig: {
15 extra: {},
16 },
17 },
18}));
19
20it.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
56it.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 @@
1import Constants from "expo-constants";
2import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing";
3
4import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig";
5
6export interface TracingConfig {
7 readonly tracesUrl: string;
8 readonly tracesDataset: string;
9 readonly tracesToken: string;
10}
11
12export interface TracingResource {
13 readonly serviceVersion?: string;
14 readonly appVariant: string;
15}
16
17export 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
26export 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
35export 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";
13import {
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";
22import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";24import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
23import { useLocalSearchParams, useRouter } from "expo-router";25import { useLocalSearchParams, useRouter } from "expo-router";
24import { SymbolView } from "expo-symbols";26import { 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
26import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";28import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
27import { useSafeAreaInsets } from "react-native-safe-area-context";29import { useSafeAreaInsets } from "react-native-safe-area-context";
28import * as Arr from "effect/Array";30import * as Arr from "effect/Array";
31import * as Cause from "effect/Cause";
29import * as Order from "effect/Order";32import * as Order from "effect/Order";
33import { AsyncResult } from "effect/unstable/reactivity";
3034
35import { useProjects, useServerConfigs } from "../../state/entities";
36import { filesystemEnvironment } from "../../state/filesystem";
37import { projectEnvironment } from "../../state/projects";
38import { useEnvironmentQuery } from "../../state/query";
39import { sourceControlEnvironment } from "../../state/sourceControl";
31import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";40import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
32import { ErrorBanner } from "../../components/ErrorBanner";41import { ErrorBanner } from "../../components/ErrorBanner";
33import { SourceControlIcon } from "../../components/SourceControlIcon";42import { SourceControlIcon } from "../../components/SourceControlIcon";
34import { useThemeColor } from "../../lib/useThemeColor";43import { useThemeColor } from "../../lib/useThemeColor";
35import { uuidv4 } from "../../lib/uuid";44import { uuidv4 } from "../../lib/uuid";
36import { getEnvironmentClient } from "../../state/environment-session-registry";45import { useAtomCommand } from "../../state/use-atom-command";
37import { useFilesystemBrowse } from "../../state/use-filesystem-browse";46import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
38import { useRemoteCatalog } from "../../state/use-remote-catalog";47import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
39import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
40import {
41 refreshSourceControlDiscoveryForEnvironment,
42 useSourceControlDiscovery,
43} from "../../state/use-source-control-discovery";
4448
45interface EnvironmentOption {49interface 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}
225229
226function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {230function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
227 const { serverConfigByEnvironmentId } = useRemoteCatalog();231 const serverConfigByEnvironmentId = useServerConfigs();
228 const { savedConnectionsById } = useRemoteEnvironmentState();232 const { savedConnectionsById } = useSavedRemoteConnections();
229233
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 );
344355
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() {
435441
436function useCreateProject(environment: EnvironmentOption | null) {442function 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();
439446
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.");
445450
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 }
463468
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}
485496
...@@ -495,6 +506,9 @@ function useEnvironmentFromParam(): EnvironmentOption | null {...@@ -495,6 +506,9 @@ function useEnvironmentFromParam(): EnvironmentOption | null {
495}506}
496507
497export function AddProjectRepositoryScreen() {508export 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 }
525539
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]);
547563
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 }
687710
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]);
697718
698 return (719 return (
...@@ -725,6 +746,9 @@ export function AddProjectLocalFolderScreen() {...@@ -725,6 +746,9 @@ export function AddProjectLocalFolderScreen() {
725}746}
726747
727export function AddProjectDestinationScreen() {748export 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 }
760784
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]);
776803
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 {
16import { useSafeAreaInsets } from "react-native-safe-area-context";16import { useSafeAreaInsets } from "react-native-safe-area-context";
1717
18import { AppText as Text } from "../../components/AppText";18import { AppText as Text } from "../../components/AppText";
19import { environmentCatalog } from "../../connection/catalog";
20import { useEnvironmentPresentation } from "../../state/presentation";
21import { useAtomCommand } from "../../state/use-atom-command";
19import { useThemeColor } from "../../lib/useThemeColor";22import { useThemeColor } from "../../lib/useThemeColor";
20import { useThreadDraftForThread } from "../../state/use-thread-composer-state";23import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
24import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
21import { useReviewCacheForThread } from "./reviewState";25import { useReviewCacheForThread } from "./reviewState";
22import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";26import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";
23import {27import {
...@@ -29,6 +33,7 @@ import { useReviewFileVisibility } from "./reviewFileVisibility";...@@ -29,6 +33,7 @@ import { useReviewFileVisibility } from "./reviewFileVisibility";
29import { useReviewSections } from "./useReviewSections";33import { useReviewSections } from "./useReviewSections";
30import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge";34import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge";
31import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController";35import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController";
36import { resolveReviewAvailability } from "./reviewAvailability";
3237
33const IOS_NAV_BAR_HEIGHT = 44;38const IOS_NAV_BAR_HEIGHT = 44;
34const REVIEW_HEADER_SPACING = 0;39const 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() {
187200
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]);
190214
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 />
314338
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.MenuAction352 <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.MenuAction364 </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>
340366
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 <View384 <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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveReviewAvailability } from "./reviewAvailability";
4
5describe("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 @@
1export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId, ReviewDiffPreviewResult } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import { AsyncResult, Atom } from "effect/unstable/reactivity";
7import { useCallback, useMemo } from "react";
8
9import { appAtomRegistry } from "../../state/atom-registry";
10import { getEnvironmentClient } from "../../state/environment-session-registry";
11
12const REVIEW_DIFF_PREVIEW_STALE_TIME_MS = 5_000;
13const REVIEW_DIFF_PREVIEW_IDLE_TTL_MS = 5 * 60_000;
14const REVIEW_DIFF_PREVIEW_KEY_SEPARATOR = "\u001f";
15
16export interface ReviewDiffPreviewState {
17 readonly data: ReviewDiffPreviewResult | null;
18 readonly error: string | null;
19 readonly isPending: boolean;
20 readonly refresh: () => void;
21}
22
23function 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
30function 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
41const 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
61const 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
65function 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
76export 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 @@
1import { useCallback, useEffect, useMemo, useRef } from "react";1import { useCallback, useEffect, useMemo } from "react";
22
3import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";3import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";
44
5import { getEnvironmentClient } from "../../state/environment-session-registry";5import { useCheckpointDiff } from "../../state/queries";
6import { checkpointDiffManager, loadCheckpointDiff } from "../../state/use-checkpoint-diff";6import { useEnvironmentQuery } from "../../state/query";
7import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";7import { reviewEnvironment } from "../../state/review";
8import { useSelectedThreadDetail } from "../../state/use-thread-detail";8import { useSelectedThreadDetail } from "../../state/use-thread-detail";
9import { useReviewDiffPreview } from "./reviewDiffPreviewState";9import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
10import {10import {
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";
2424
25export function useReviewSections(input: {25export 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]);
4344
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.selectedSectionId95 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 );
98100
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 &&
168105 (!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 ]);
179116
180 const latestCheckpoint = readyCheckpoints[0] ?? null;117 let activeCheckpoint = readyCheckpoints[0] ?? null;
181 const latestSectionId = latestCheckpoint118 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 = latestSectionId124 const activeTurnDiff = useCheckpointDiff({
185 ? reviewCache.turnDiffById[latestSectionId] !== undefined125 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 });
188132
189 useEffect(() => {133 useEffect(() => {
190 if (!latestCheckpoint || !latestSectionId || latestTurnDiffLoaded || latestTurnDiffLoading) {134 if (!reviewCache.threadKey || !activeSectionId) {
191 return;135 return;
192 }136 }
193137 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;
209139
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]);
214147
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]);
217153
218 const refreshSelectedSection = useCallback(async () => {154 const refreshSelectedSection = useCallback(async () => {
219 if (!selectedSection) {155 if (!enabled) {
220 return;156 return;
221 }157 }
222158 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 }
230162 diffPreview.refresh();
231 refreshDiffPreview();163 }, [activeTurnDiff, diffPreview, enabled, selectedSection?.kind]);
232 }, [checkpointBySectionId, loadTurnDiff, refreshDiffPreview, selectedSection]);
233164
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 );
242173
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 @@
1import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts";1import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { memo, useCallback, useEffect, useRef, useState } from "react";3import { memo, useCallback, useEffect, useMemo, useRef } from "react";
4import { Pressable, View } from "react-native";4import { Pressable, View } from "react-native";
55
6import { AppText as Text } from "../../components/AppText";6import { AppText as Text } from "../../components/AppText";
7import { getEnvironmentClient } from "../../state/environment-session-registry";7import { terminalEnvironment } from "../../state/terminal";
8import {8import { useAtomCommand } from "../../state/use-atom-command";
9 attachTerminalSession,9import { useAttachedTerminalSession } from "../../state/use-terminal-session";
10 useTerminalSession,
11 useTerminalSessionTarget,
12} from "../../state/use-terminal-session";
13import { TerminalSurface } from "./NativeTerminalSurface";10import { TerminalSurface } from "./NativeTerminalSurface";
14import { hasNativeTerminalSurface } from "./nativeTerminalModule";11import { hasNativeTerminalSurface } from "./nativeTerminalModule";
15import { terminalDebugLog } from "./terminalDebugLog";12import {
13 buildThreadTerminalAttachInput,
14 type TerminalGridSize,
15 type ThreadTerminalSubscriptionIdentity,
16} from "./threadTerminalPanelModel";
1617
17interface ThreadTerminalPanelProps {18interface 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;
29export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(30export 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 });
4662
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";
4965
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],
6379 );
64 terminalDebugLog("panel:attach", {
65 environmentId: props.environmentId,
66 threadId: props.threadId,
67 terminalId,
68 });
6980
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 ]);
9086
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 }
9792
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 );
106104
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 }
112111
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 }
118116
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 );
135121
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 @@
1import {1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
2 DEFAULT_TERMINAL_ID,2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
3 EnvironmentId,
4 type TerminalAttachStreamEvent,
5 ThreadId,
6} from "@t3tools/contracts";
7import type { KnownTerminalSession } from "@t3tools/client-runtime";
8import { SymbolView } from "expo-symbols";3import { SymbolView } from "expo-symbols";
9import { Stack, useLocalSearchParams, useRouter } from "expo-router";4import { Stack, useLocalSearchParams, useRouter } from "expo-router";
10import { useCallback, useEffect, useMemo, useRef, useState } from "react";5import { useCallback, useEffect, useMemo, useRef, useState } from "react";
...@@ -24,17 +19,19 @@ import {...@@ -24,17 +19,19 @@ import {
24import { EmptyState } from "../../components/EmptyState";19import { EmptyState } from "../../components/EmptyState";
25import { GlassSurface } from "../../components/GlassSurface";20import { GlassSurface } from "../../components/GlassSurface";
26import { LoadingScreen } from "../../components/LoadingScreen";21import { LoadingScreen } from "../../components/LoadingScreen";
22import { environmentCatalog } from "../../connection/catalog";
23import { useEnvironmentPresentation } from "../../state/presentation";
24import { terminalEnvironment } from "../../state/terminal";
25import { useAtomCommand } from "../../state/use-atom-command";
26import { useWorkspaceState } from "../../state/workspace";
27import { buildThreadTerminalNavigation } from "../../lib/routes";27import { buildThreadTerminalNavigation } from "../../lib/routes";
28import { getEnvironmentClient } from "../../state/environment-session-registry";
29import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
30import {28import {
31 attachTerminalSession,29 useAttachedTerminalSession,
32 useKnownTerminalSessions,30 useKnownTerminalSessions,
33 useTerminalSession,
34 useTerminalSessionTarget,
35} from "../../state/use-terminal-session";31} from "../../state/use-terminal-session";
36import { useThreadSelection } from "../../state/use-thread-selection";32import { useThreadSelection } from "../../state/use-thread-selection";
37import { useSelectedThreadDetail } from "../../state/use-thread-detail";33import { useSelectedThreadDetail } from "../../state/use-thread-detail";
34import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
38import { TerminalSurface } from "./NativeTerminalSurface";35import { TerminalSurface } from "./NativeTerminalSurface";
39import { getPierreTerminalTheme } from "./terminalTheme";36import { getPierreTerminalTheme } from "./terminalTheme";
40import { loadPreferences, savePreferencesPatch } from "../../lib/storage";37import { 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";
47import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap";
48import {44import {
49 resolveTerminalOpenLocation,45 resolveTerminalOpenLocation,
50 stagePendingTerminalLaunch,
51 takePendingTerminalLaunch,46 takePendingTerminalLaunch,
47 type PendingTerminalLaunch,
52} from "./terminalLaunchContext";48} from "./terminalLaunchContext";
53import {49import {
54 basename,50 basename,
...@@ -158,8 +154,12 @@ function pickRunningTerminalSessionForBootstrap(...@@ -158,8 +154,12 @@ function pickRunningTerminalSessionForBootstrap(
158154
159export function ThreadTerminalRouteScreen() {155export 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 = selectedThread333 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 };
313404
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 );
408499
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 ]);
436543
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]);
445550
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 ]);
526578
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]);
549583
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]);
640674
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 }
721680
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,
726685 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 );
735692
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 }
784741
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,
789746 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 }
857813
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]);
869823
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]);
908867
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 }
913872
...@@ -932,6 +891,10 @@ export function ThreadTerminalRouteScreen() {...@@ -932,6 +891,10 @@ export function ThreadTerminalRouteScreen() {
932 );891 );
933 }892 }
934893
894 if (!environment.isReady && environment.presentation === null) {
895 return <LoadingScreen message="Opening terminal…" />;
896 }
897
935 return (898 return (
936 <>899 <>
937 <Stack.Screen900 <Stack.Screen
...@@ -980,152 +943,178 @@ export function ThreadTerminalRouteScreen() {...@@ -980,152 +943,178 @@ export function ThreadTerminalRouteScreen() {
980 }}943 }}
981 />944 />
982945
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.MenuAction956 <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.MenuAction987 <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.MenuAction996 ) : 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>
1029997
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 <TerminalSurface1000 <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>
10431030
1044 {isAccessoryVisible ? (1031 {isAccessoryVisible ? (
1045 <KeyboardStickyView1032 <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 <View1036 <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}
10661047 fadeOpaque={terminalTheme.background}
1067 return (1048 fadeTransparent={`${terminalTheme.background}00`}
1068 <ComposerToolbarButton1049 >
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 <ComposerToolbarButton1066 : "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 <Pressable1076 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 <GlassSurface1088 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 <SymbolView1101 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 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import type { KnownTerminalSession } from "@t3tools/client-runtime";3import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
4import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";4import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
55
6import { getTerminalLabel } from "@t3tools/shared/terminalLabels";6import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
apps/mobile/src/features/terminal/terminalMenu.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { KnownTerminalSession } from "@t3tools/client-runtime";1import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
2import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts";2import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts";
3import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";3import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
4import * as Arr from "effect/Array";4import * as Arr from "effect/Array";
apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts created+40
...@@ -0,0 +1,40 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 buildThreadTerminalAttachInput,
6 threadTerminalSubscriptionKey,
7 type ThreadTerminalSubscriptionIdentity,
8} from "./threadTerminalPanelModel";
9
10const 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
18describe("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 @@
1import type { EnvironmentId, TerminalAttachInput } from "@t3tools/contracts";
2
3export 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
11export interface TerminalGridSize {
12 readonly cols: number;
13 readonly rows: number;
14}
15
16export 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
28export 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 @@
1import { Stack, useRouter } from "expo-router";1import { Stack, useRouter } from "expo-router";
2import { useCallback, useEffect, useMemo, useRef } from "react";2import { useCallback, useEffect, useMemo, useRef } from "react";
3import { InteractionManager, View, useColorScheme } from "react-native";3import { Alert, InteractionManager, View, useColorScheme } from "react-native";
4import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";4import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
6import { useThemeColor } from "../../lib/useThemeColor";6import { useThemeColor } from "../../lib/useThemeColor";
77
8import { EnvironmentId, type ModelSelection } from "@t3tools/contracts";8import { EnvironmentId } from "@t3tools/contracts";
9import {
10 isAtomCommandInterrupted,
11 squashAtomCommandFailure,
12} from "@t3tools/client-runtime/state/runtime";
913
10import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";14import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
11import {15import {
...@@ -19,27 +23,16 @@ import { ControlPillMenu } from "../../components/ControlPill";...@@ -19,27 +23,16 @@ import { ControlPillMenu } from "../../components/ControlPill";
19import { ProviderIcon } from "../../components/ProviderIcon";23import { ProviderIcon } from "../../components/ProviderIcon";
2024
21import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";25import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
26import {
27 applyProviderOptionMenuEvent,
28 buildProviderOptionMenuActions,
29 providerOptionsConfigurationLabel,
30 resolveProviderOptionDescriptors,
31} from "../../lib/providerOptions";
22import { buildThreadRoutePath } from "../../lib/routes";32import { buildThreadRoutePath } from "../../lib/routes";
23import { useRemoteCatalog } from "../../state/use-remote-catalog";33import { useProjects } from "../../state/entities";
24import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions";
25import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";34import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
26import { useProjectActions } from "./use-project-actions";35import { useCreateProjectThread } from "./use-project-actions";
27
28function 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
40function formatTitleCase(value: string): string {
41 return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
42}
4336
44function formatWorkspaceLabel(input: {37function 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 );
172173
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 );
246218
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 }
339307
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 }
416377
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)) {
433394 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 }
455407
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}
1515
16export function PendingApprovalCard(props: PendingApprovalCardProps) {16export 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}
2525
26export function PendingUserInputCard(props: PendingUserInputCardProps) {26export 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
2import type {2import 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";
16import type { ReactNode } from "react";16import type { ReactNode } from "react";
17import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";17import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
18import { Image, Pressable, useColorScheme, View, type ViewStyle } from "react-native";18import {
19 ActivityIndicator,
20 Image,
21 Pressable,
22 useColorScheme,
23 View,
24 type ViewStyle,
25} from "react-native";
19import ImageViewing from "react-native-image-viewing";26import ImageViewing from "react-native-image-viewing";
20import { useThemeColor } from "../../lib/useThemeColor";27import { useThemeColor } from "../../lib/useThemeColor";
2128
...@@ -43,11 +50,12 @@ import {...@@ -43,11 +50,12 @@ import {
43 scoreQueryMatch,50 scoreQueryMatch,
44} from "@t3tools/shared/searchRanking";51} from "@t3tools/shared/searchRanking";
45import {52import {
46 getModelSelectionBooleanOptionValue,53 applyProviderOptionMenuEvent,
47 getModelSelectionStringOptionValue,54 buildProviderOptionMenuActions,
48} from "@t3tools/shared/model";55 providerOptionsConfigurationLabel,
56 resolveProviderOptionDescriptors,
57} from "../../lib/providerOptions";
49import { useComposerPathSearch } from "../../state/use-composer-path-search";58import { useComposerPathSearch } from "../../state/use-composer-path-search";
50import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions";
51import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";59import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
5260
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}
89100
...@@ -126,21 +137,67 @@ function ComposerSurface(props: {...@@ -126,21 +137,67 @@ function ComposerSurface(props: {
126 );137 );
127}138}
128139
129function withModelSelectionOption(140function 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}
140172
141function formatTitleCase(value: string): string {173const 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});
144201
145export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {202export 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;
158215
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;
187243
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]);
202266
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;
395447
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 }
419472
...@@ -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 );
566588
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 }
578600
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 }
622620
...@@ -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}
654652
653 {connectionStatus ? (
654 <ComposerConnectionStatusPill
655 status={connectionStatus}
656 onPress={props.onReconnectEnvironment}
657 />
658 ) : null}
659
655 <ComposerSurface660 <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 <ControlPill763 <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 <ControlPill765 <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 <ComposerToolbarButton810 <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 @@
1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
1import type {2import 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";
23import type { ComposerEditorHandle } from "../../components/ComposerEditor";24import type { ComposerEditorHandle } from "../../components/ComposerEditor";
24import type { StatusTone } from "../../components/StatusPill";25import type { StatusTone } from "../../components/StatusPill";
25import type { DraftComposerImageAttachment } from "../../lib/composerImages";26import type { DraftComposerImageAttachment } from "../../lib/composerImages";
26import type { MobileLayoutVariant } from "../../lib/mobileLayout";27import type { LayoutVariant } from "../../lib/layout";
27import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout";28import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout";
28import type {29import 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";
41import { ThreadFeed } from "./ThreadFeed";42import { ThreadFeed } from "./ThreadFeed";
43import type { ThreadContentPresentation } from "./threadContentPresentation";
4244
43export interface ThreadDetailScreenProps {45export 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}
9697
...@@ -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 <ThreadFeed308 <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";
2import * as Haptics from "expo-haptics";2import * as Haptics from "expo-haptics";
3import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";3import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";
4import { type LegendListRef } from "@legendapp/list/react-native";4import { type LegendListRef } from "@legendapp/list/react-native";
5import type { ThreadId, TurnId } from "@t3tools/contracts";5import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts";
6import { SymbolView } from "expo-symbols";6import { SymbolView } from "expo-symbols";
7import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";7import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
8import {8import {
...@@ -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";
14import {14import {
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";
3839
39import { AppText as Text } from "../../components/AppText";40import { AppText as Text } from "../../components/AppText";
40import { EmptyState } from "../../components/EmptyState";
41import { CopyTextButton } from "../../components/CopyTextButton";41import { CopyTextButton } from "../../components/CopyTextButton";
42import {42import {
43 parseReviewCommentMessageSegments,43 parseReviewCommentMessageSegments,
...@@ -53,7 +53,7 @@ import {...@@ -53,7 +53,7 @@ import {
53} from "../review/nativeReviewDiffAdapter";53} from "../review/nativeReviewDiffAdapter";
54import { buildReviewParsedDiff } from "../review/reviewModel";54import { buildReviewParsedDiff } from "../review/reviewModel";
55import { cn } from "../../lib/cn";55import { cn } from "../../lib/cn";
56import type { MobileLayoutVariant } from "../../lib/mobileLayout";56import type { LayoutVariant } from "../../lib/layout";
57import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";57import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
58import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";58import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
59import {59import {
...@@ -63,7 +63,8 @@ import {...@@ -63,7 +63,8 @@ import {
63} from "../../lib/threadActivity";63} from "../../lib/threadActivity";
64import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout";64import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout";
65import { relativeTime } from "../../lib/time";65import { relativeTime } from "../../lib/time";
66import { messageImageUrl } from "./threadPresentation";66import type { ThreadContentPresentation } from "./threadContentPresentation";
67import { useAssetUrl } from "../../state/assets";
6768
68const THREAD_FEED_END_THRESHOLD = 80;69const THREAD_FEED_END_THRESHOLD = 80;
69const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {70const 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}
8182
82export interface ThreadFeedProps {83export 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}
9596
97function 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
96function stripShellWrapper(value: string): string {123function 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 {
654681
655function renderFeedEntry(682function 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 <TouchableOpacity764 <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 <Image769 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 <TouchableOpacity820 <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 <Image826 />
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}
12221224
1225function 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
1223export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {1256export 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 );
14891520
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 <ScrollView1548 <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 }
15101557
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";
13import { useLocalSearchParams, useRouter } from "expo-router";13import { useLocalSearchParams, useRouter } from "expo-router";
14import Stack from "expo-router/stack";14import Stack from "expo-router/stack";
15import { useCallback, useMemo } from "react";15import { useCallback, useMemo } from "react";
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx+119-96
...@@ -1,6 +1,13 @@...@@ -1,6 +1,13 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "expo-symbols";
2import { useCallback, useEffect, useMemo, useState } from "react";2import { useCallback, useEffect, useMemo, useState } from "react";
3import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native";3import {
4 type ColorValue,
5 Modal,
6 Pressable,
7 ScrollView,
8 useWindowDimensions,
9 View,
10} from "react-native";
4import * as Arr from "effect/Array";11import * as Arr from "effect/Array";
5import * as Order from "effect/Order";12import * as Order from "effect/Order";
6import { Gesture, GestureDetector } from "react-native-gesture-handler";13import { 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";
1522
16import { AppText as Text } from "../../components/AppText";23import { AppText as Text } from "../../components/AppText";
17import { StatusPill } from "../../components/StatusPill";24import { StatusPill } from "../../components/StatusPill";
25import { useProjects, useThreadShells } from "../../state/entities";
18import { groupProjectsByRepository } from "../../lib/repositoryGroups";26import { groupProjectsByRepository } from "../../lib/repositoryGroups";
19import { scopedThreadKey } from "../../lib/scopedEntities";27import { scopedThreadKey } from "../../lib/scopedEntities";
20import { relativeTime } from "../../lib/time";28import { relativeTime } from "../../lib/time";
21import { threadStatusTone } from "./threadPresentation";29import { threadStatusTone } from "./threadPresentation";
22import {30import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
23 EnvironmentScopedProjectShell,
24 EnvironmentScopedThreadShell,
25} from "@t3tools/client-runtime";
2631
27const threadActivityOrder = Order.mapInput(32const 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(
3742
38export function ThreadNavigationDrawer(props: {43export 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");
5962
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>
188171
189 <ScrollView172 <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
186function 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 @@
1import { Stack, useLocalSearchParams, useRouter } from "expo-router";1import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2import { useCallback, useMemo, useState } from "react";2import { useCallback, useMemo, useState } from "react";
3import * as Arr from "effect/Array";
4import * as Option from "effect/Option";3import * as Option from "effect/Option";
5import { pipe } from "effect/Function";4import {
6import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";5 EnvironmentId,
6 type ModelSelection,
7 type ProjectScript,
8 type ProviderInteractionMode,
9 type RuntimeMode,
10} from "@t3tools/contracts";
7import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";11import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
8import { Pressable, ScrollView, Text as RNText, View } from "react-native";12import { Pressable, ScrollView, Text as RNText, View } from "react-native";
13import { useWorkspaceState } from "../../state/workspace";
9import { useThemeColor } from "../../lib/useThemeColor";14import { useThemeColor } from "../../lib/useThemeColor";
10import { useVcsStatus } from "../../state/use-vcs-status";15import { useEnvironmentQuery } from "../../state/query";
11import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";16import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
17import { vcsEnvironment } from "../../state/vcs";
1218
13import { EmptyState } from "../../components/EmptyState";19import { EmptyState } from "../../components/EmptyState";
14import { LoadingScreen } from "../../components/LoadingScreen";20import { LoadingScreen } from "../../components/LoadingScreen";
...@@ -16,13 +22,13 @@ import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/r...@@ -16,13 +22,13 @@ import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/r
16import { scopedThreadKey } from "../../lib/scopedEntities";22import { scopedThreadKey } from "../../lib/scopedEntities";
17import { connectionTone } from "../connection/connectionTone";23import { connectionTone } from "../connection/connectionTone";
1824
19import { useRemoteCatalog } from "../../state/use-remote-catalog";
20import {25import {
26 useRemoteConnections,
21 useRemoteConnectionStatus,27 useRemoteConnectionStatus,
22 useRemoteEnvironmentState,28 useRemoteEnvironmentRuntime,
23} from "../../state/use-remote-environment-registry";29} from "../../state/use-remote-environment-registry";
24import { useKnownTerminalSessions } from "../../state/use-terminal-session";30import { useKnownTerminalSessions } from "../../state/use-terminal-session";
25import { useSelectedThreadDetail } from "../../state/use-thread-detail";31import { useSelectedThreadDetailState } from "../../state/use-thread-detail";
26import { useThreadSelection } from "../../state/use-thread-selection";32import { useThreadSelection } from "../../state/use-thread-selection";
27import { GitActionProgressOverlay } from "./GitActionProgressOverlay";33import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
28import {34import {
...@@ -38,12 +44,14 @@ import { terminalDebugLog } from "../terminal/terminalDebugLog";...@@ -38,12 +44,14 @@ import { terminalDebugLog } from "../terminal/terminalDebugLog";
38import { ThreadDetailScreen } from "./ThreadDetailScreen";44import { ThreadDetailScreen } from "./ThreadDetailScreen";
39import { ThreadGitControls } from "./ThreadGitControls";45import { ThreadGitControls } from "./ThreadGitControls";
40import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";46import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
41import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands";47import { useAtomCommand } from "../../state/use-atom-command";
42import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";48import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
43import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";49import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
44import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";50import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
45import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";51import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
46import { useThreadComposerState } from "../../state/use-thread-composer-state";52import { useThreadComposerState } from "../../state/use-thread-composer-state";
53import { threadEnvironment } from "../../state/threads";
54import { projectThreadContentPresentation } from "./threadContentPresentation";
4755
48function firstRouteParam(value: string | string[] | undefined): string | null {56function 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}
5967
60export function ThreadRouteScreen() {68export 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 = environmentId103 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;
92107
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"));
97112
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]);
116141
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]);
134222
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() {
238326
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";
244332
...@@ -265,19 +353,14 @@ export function ThreadRouteScreen() {...@@ -265,19 +353,14 @@ export function ThreadRouteScreen() {
265 );353 );
266 }354 }
267355
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 );
281364
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 <RNText401 <RNText
319 numberOfLines={1}402 numberOfLines={1}
...@@ -333,7 +416,7 @@ export function ThreadRouteScreen() {...@@ -333,7 +416,7 @@ export function ThreadRouteScreen() {
333 />416 />
334417
335 <ThreadGitControls418 <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() {
350433
351 <View className="flex-1 bg-screen">434 <View className="flex-1 bg-screen">
352 <ThreadDetailScreen435 <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() {
390474
391 <ThreadNavigationDrawer475 <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 @@
1export const CLAUDE_AGENT_EFFORT_OPTIONS = [
2 "low",
3 "medium",
4 "high",
5 "xhigh",
6 "max",
7 "ultrathink",
8] as const;
9
10export 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";
6import { useThemeColor } from "../../../lib/useThemeColor";6import { useThemeColor } from "../../../lib/useThemeColor";
77
8import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";8import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
9import { useVcsStatus } from "../../../state/use-vcs-status";9import { useEnvironmentQuery } from "../../../state/query";
10import { useThreadSelection } from "../../../state/use-thread-selection";10import { useThreadSelection } from "../../../state/use-thread-selection";
11import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";11import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
12import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";12import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
13import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";13import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
14import { vcsEnvironment } from "../../../state/vcs";
14import { SheetActionButton } from "./gitSheetComponents";15import { SheetActionButton } from "./gitSheetComponents";
1516
16export function GitBranchesSheet() {17export 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");
2930
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 );
3439
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";
5import { useThemeColor } from "../../../lib/useThemeColor";5import { useThemeColor } from "../../../lib/useThemeColor";
66
7import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";7import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
8import { useVcsStatus } from "../../../state/use-vcs-status";8import { useEnvironmentQuery } from "../../../state/query";
9import { useThreadSelection } from "../../../state/use-thread-selection";9import { useThreadSelection } from "../../../state/use-thread-selection";
10import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";10import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
11import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";11import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
12import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";12import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
13import { vcsEnvironment } from "../../../state/vcs";
13import { SheetActionButton } from "./gitSheetComponents";14import { SheetActionButton } from "./gitSheetComponents";
1415
15export function GitCommitSheet() {16export 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");
2930
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 );
3439
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 @@
1import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime";1import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/state/vcs";
2import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";2import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
3import * as Arr from "effect/Array";3import * as Arr from "effect/Array";
4import * as Result from "effect/Result";4import * 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";
7import type { EnvironmentId, ThreadId } from "@t3tools/contracts";7import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import { useLocalSearchParams, useRouter } from "expo-router";8import { useLocalSearchParams, useRouter } from "expo-router";
9import { SymbolView } from "expo-symbols";9import { SymbolView } from "expo-symbols";
...@@ -14,11 +14,12 @@ import { useThemeColor } from "../../../lib/useThemeColor";...@@ -14,11 +14,12 @@ import { useThemeColor } from "../../../lib/useThemeColor";
1414
15import { AppText as Text } from "../../../components/AppText";15import { AppText as Text } from "../../../components/AppText";
16import { buildThreadReviewRoutePath } from "../../../lib/routes";16import { buildThreadReviewRoutePath } from "../../../lib/routes";
17import { useVcsStatus } from "../../../state/use-vcs-status";17import { useEnvironmentQuery } from "../../../state/query";
18import { useThreadSelection } from "../../../state/use-thread-selection";18import { useThreadSelection } from "../../../state/use-thread-selection";
19import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";19import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
20import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";20import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
21import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";21import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
22import { vcsEnvironment } from "../../../state/vcs";
22import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";23import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
2324
24export function GitOverviewSheet() {25export 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");
3839
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 );
4348
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 @@
1import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";1import React, { useCallback, useMemo, useRef, useState } from "react";
22
3import type {3import 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
11import * as Arr from "effect/Array";12import * as Arr from "effect/Array";
12import { pipe } from "effect/Function";13import { pipe } from "effect/Function";
1314
15import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";
14import type { DraftComposerImageAttachment } from "../../lib/composerImages";16import type { DraftComposerImageAttachment } from "../../lib/composerImages";
15import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";17import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
16import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";18import { 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";
26import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs";28import { useBranches } from "../../state/queries";
27import { useRemoteCatalog } from "../../state/use-remote-catalog";
28import {29import {
29 setPendingConnectionError,30 setPendingConnectionError,
30 useRemoteEnvironmentState,31 useSavedRemoteConnections,
31} from "../../state/use-remote-environment-registry";32} from "../../state/use-remote-environment-registry";
32import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime";33import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
33import type { ClaudeAgentEffort } from "./claudeEffortOptions";34import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
3435
35type WorkspaceMode = "local" | "worktree";36type WorkspaceMode = "local" | "worktree";
3637
37function normalizeSelectedWorktreePath(38function 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(
4745
48export function branchBadgeLabel(input: {46export 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: {
67type NewTaskFlowContextValue = {65type 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};
122117
123const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(null);118const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(null);
124119
125export function NewTaskFlowProvider(props: React.PropsWithChildren) {120export 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();
128124
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 );
155151
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);
175176
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]);
207191
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 = selectedProject244 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 selectedProject254 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 );
274259
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;
280277
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 (selectedProject286 selectedEnvironmentServerConfig?.providers.find(
290 ? serverConfigByEnvironmentId[selectedProject.environmentId]287 (provider) => provider.instanceId === selectedModel?.instanceId,
291 : null288 )?.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 );
294307
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]);
368381
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 }, []);
377391
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 }, []);
385400
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) {
400415
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 ]);
434440
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 );
524523
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}
545526
apps/mobile/src/features/threads/threadContentPresentation.test.ts created+57
...@@ -0,0 +1,57 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { projectThreadContentPresentation } from "./threadContentPresentation";
4
5describe("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 @@
1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2
3export 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
12export 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 @@
1import type { StatusTone } from "../../components/StatusPill";1import type { StatusTone } from "../../components/StatusPill";
2import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";2import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
33
4export function threadSortValue(thread: EnvironmentScopedThreadShell): number {4export 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}
88
9export function threadStatusTone(thread: EnvironmentScopedThreadShell): StatusTone {9export 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
46export 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 @@
1import { useCallback } from "react";1import { useCallback } from "react";
22
3import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime";3import { scopeThreadRef } from "@t3tools/client-runtime/environment";
4import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
5import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime";
4import {6import {
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";
15import { buildTemporaryWorktreeBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git";14import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
16import { uuidv4 } from "../../lib/uuid";15import * as Cause from "effect/Cause";
16import { AsyncResult } from "effect/unstable/reactivity";
1717
18import { threadEnvironment } from "../../state/threads";
18import type { DraftComposerImageAttachment } from "../../lib/composerImages";19import type { DraftComposerImageAttachment } from "../../lib/composerImages";
19import { makeTurnCommandMetadata } from "../../lib/commandMetadata";20import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
20import { getEnvironmentClient } from "../../state/environment-session-registry";21import { uuidv4 } from "../../lib/uuid";
21import { environmentRuntimeManager } from "../../state/use-environment-runtime";22import { useAtomCommand } from "../../state/use-atom-command";
22import { vcsRefManager } from "../../state/use-vcs-refs";23import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
23import { useRemoteCatalog } from "../../state/use-remote-catalog";
24import {
25 setPendingConnectionError,
26 useRemoteEnvironmentState,
27} from "../../state/use-remote-environment-registry";
28
29function 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}
6524
66function deriveThreadTitleFromPrompt(value: string): string {25function 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}
7534
76export function useProjectActions() {35export function useCreateProjectThread() {
77 const { threads } = useRemoteCatalog();36 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
78 const refreshRemoteData = useRefreshRemoteData();
7937
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);
10254
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 }
10965
110 const isWorktree = input.envMode === "worktree";66 const isWorktree = input.envMode === "worktree";
11167 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 ...(isWorktree104 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);
228115
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 @@
1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
2import { Platform } from "react-native";2import { Platform } from "react-native";
33
4export function mobileAuthClientMetadata(): AuthClientPresentationMetadata {4export 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";
33
4import {4import {
5 isRelayManagedConnection,5 isRelayManagedConnection,
6 mobileAuthClientMetadata,6 authClientMetadata,
7 redactPairingCredential,7 redactPairingCredential,
8 toStableSavedRemoteConnection,8 toStableSavedRemoteConnection,
9} from "./connection";9} from "./connection";
1010
11vi.mock("./runtime", () => ({11vi.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", () => ({
2222
23describe("mobile remote connection records", () => {23describe("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 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import {2import { stripPairingTokenFromUrl } from "@t3tools/shared/remote";
3 bootstrapRemoteBearerSession,3import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
4 fetchRemoteEnvironmentDescriptor,
5} from "@t3tools/client-runtime";
6import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";
7import * as Effect from "effect/Effect";
8import { mobileAuthClientMetadata } from "./authClientMetadata";
9import { mobileRuntime } from "./runtime";
104
11export { mobileAuthClientMetadata } from "./authClientMetadata";5export { authClientMetadata } from "./authClientMetadata";
12
13export interface RemoteConnectionInput {
14 readonly pairingUrl: string;
15}
166
17export interface SavedRemoteConnection {7export 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}
2919
30export type RemoteClientConnectionState =20export type RemoteClientConnectionState = EnvironmentConnectionPhase;
31 | "idle"
32 | "connecting"
33 | "ready"
34 | "reconnecting"
35 | "disconnected";
3621
37export function redactPairingCredential(pairingUrl: string): string {22export 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
63export 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 @@
1function clamp(value: number, min: number, max: number): number {
2 return Math.min(Math.max(value, min), max);
3}
4
5export type LayoutVariant = "compact" | "split";
6
7export interface Layout {
8 readonly variant: LayoutVariant;
9 readonly usesSplitView: boolean;
10 readonly listPaneWidth: number | null;
11 readonly shellPadding: number;
12}
13
14export 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 @@
1function clamp(value: number, min: number, max: number): number {
2 return Math.min(Math.max(value, min), max);
3}
4
5export type MobileLayoutVariant = "compact" | "split";
6
7export interface MobileLayout {
8 readonly variant: MobileLayoutVariant;
9 readonly usesSplitView: boolean;
10 readonly listPaneWidth: number | null;
11 readonly shellPadding: number;
12}
13
14export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
4
5import { buildModelOptions } from "./modelOptions";
6
7describe("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 @@
1import type { ModelSelection, ServerConfig as T3ServerConfig } from "@t3tools/contracts";1import type {
2 ModelCapabilities,
3 ModelSelection,
4 ServerConfig as T3ServerConfig,
5} from "@t3tools/contracts";
6import {
7 buildProviderOptionSelectionsFromDescriptors,
8 getProviderOptionDescriptors,
9} from "@t3tools/shared/model";
210
3export type ModelOption = {11export 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};
1221
...@@ -27,6 +36,27 @@ function providerDisplayLabel(provider: {...@@ -27,6 +36,27 @@ function providerDisplayLabel(provider: {
27 return provider.instanceId;36 return provider.instanceId;
28}37}
2938
39function 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
30export function buildModelOptions(60export 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 }
5892
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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { ModelCapabilities } from "@t3tools/contracts";
4
5import {
6 applyProviderOptionMenuEvent,
7 buildProviderOptionMenuActions,
8 providerOptionsConfigurationLabel,
9 resolveProviderOptionDescriptors,
10} from "./providerOptions";
11
12const 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
37describe("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 @@
1import type {
2 ModelCapabilities,
3 ProviderOptionDescriptor,
4 ProviderOptionSelection,
5} from "@t3tools/contracts";
6import type { MenuAction } from "@react-native-menu/menu";
7import {
8 buildProviderOptionSelectionsFromDescriptors,
9 getProviderOptionCurrentLabel,
10 getProviderOptionCurrentValue,
11 getProviderOptionDescriptors,
12} from "@t3tools/shared/model";
13
14const PROVIDER_OPTION_EVENT_PREFIX = "provider-option:";
15
16function providerOptionEvent(id: string, value: string | boolean): string {
17 return `${PROVIDER_OPTION_EVENT_PREFIX}${encodeURIComponent(JSON.stringify({ id, value }))}`;
18}
19
20function 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
48export 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
61export 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
96export 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
109export 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";
3import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";3import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
44
5import { groupProjectsByRepository } from "./repositoryGroups";5import { groupProjectsByRepository } from "./repositoryGroups";
6import {6import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
7 EnvironmentScopedProjectShell,
8 EnvironmentScopedThreadShell,
9} from "@t3tools/client-runtime";
107
11function makeProject(8function 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}
2521
26function makeThread(22function 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";
3import type { RepositoryIdentity } from "@t3tools/contracts";3import type { RepositoryIdentity } from "@t3tools/contracts";
44
5import { scopedProjectKey } from "./scopedEntities";5import { scopedProjectKey } from "./scopedEntities";
6import {6import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
7 EnvironmentScopedProjectShell,
8 EnvironmentScopedThreadShell,
9} from "@t3tools/client-runtime";
107
11const DateDescending = Order.flip(Order.Date);8const DateDescending = Order.flip(Order.Date);
129
13export interface MobileRepositoryProjectGroup {10export 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}
1916
20export interface MobileRepositoryGroup {17export 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}
3027
31function compareIsoDateDescending(left: string, right: string): number {28function 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}
3431
35function deriveRepositoryGroupKey(project: EnvironmentScopedProjectShell): string {32function 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}
4037
41function deriveRepositoryTitle(project: EnvironmentScopedProjectShell): string {38function 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}
5552
56function deriveProjectLatestActivity(53function 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}
6360
64export function groupProjectsByRepository(input: {61export 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[]>();
6966
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 }
7976
80 const grouped = new Map<string, MobileRepositoryGroup>();77 const grouped = new Map<string, RepositoryGroup>();
8178
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 );
9087
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 @@
1import type { Href, useRouter } from "expo-router";1import type { Href, useRouter } from "expo-router";
2import type { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";2import { type EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
44
5import type { SelectedThreadRef } from "../state/remote-runtime-types";5import 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>;
88
9type ThreadRouteInput =9type ThreadRouteInput =
10 | Pick<SelectedThreadRef, "environmentId" | "threadId">10 | Pick<SelectedThreadRef, "environmentId" | "threadId">
11 | Pick<EnvironmentScopedThreadShell, "environmentId" | "id">;11 | Pick<EnvironmentThreadShell, "environmentId" | "id">;
12type PlainThreadRouteInput =12type 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 @@
1import * as Layer from "effect/Layer";1import * as Layer from "effect/Layer";
2import * as ManagedRuntime from "effect/ManagedRuntime";2import * as ManagedRuntime from "effect/ManagedRuntime";
3import * as Socket from "effect/unstable/socket/Socket";
34
4import { remoteHttpClientLayer } from "@t3tools/client-runtime";5import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
56
6import { mobileCryptoLayer } from "../features/cloud/dpop";7import { cryptoLayer } from "../features/cloud/dpop";
7import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer";8import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer";
8import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";9import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";
9import { mobileTracingLayer } from "../features/observability/mobileTracing";10import { tracingLayer } from "../features/observability/tracing";
1011
11function configuredRelayUrl(): string {12function configuredRelayUrl(): string {
12 return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid";13 return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid";
13}14}
1415
15const mobileHttpClientLayer = remoteHttpClientLayer(fetch);16const httpClientLayer = remoteHttpClientLayer(fetch);
1617
17export const mobileRuntime = ManagedRuntime.make(18export 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);
2426
25export const mobileRuntimeContextLayer = Layer.effectContext(mobileRuntime.contextEffect);27export const runtime = ManagedRuntime.make(runtimeLayer);
28
29export 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}));
2626
27vi.mock("./runtime", () => ({27vi.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 @@
1import * as Arr from "effect/Array";1import * as Arr from "effect/Array";
2import { pipe } from "effect/Function";2import { pipe } from "effect/Function";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import * as SecureStore from "expo-secure-store";3import * as SecureStore from "expo-secure-store";
6import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts";4import { EnvironmentId } from "@t3tools/contracts";
75
8import {6import {
9 isRelayManagedConnection,7 isRelayManagedConnection,
...@@ -14,29 +12,12 @@ import {...@@ -14,29 +12,12 @@ import {
14const CONNECTIONS_KEY = "t3code.connections";12const CONNECTIONS_KEY = "t3code.connections";
15const PREFERENCES_KEY = "t3code.preferences";13const PREFERENCES_KEY = "t3code.preferences";
16const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";14const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
17const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
18const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
19
20export interface CachedShellSnapshot {
21 readonly schemaVersion: typeof SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION;
22 readonly environmentId: EnvironmentId;
23 readonly snapshotReceivedAt: string;
24 readonly snapshot: OrchestrationShellSnapshot;
25}
2615
27export interface MobilePreferences {16export interface Preferences {
28 readonly liveActivitiesEnabled?: boolean;17 readonly liveActivitiesEnabled?: boolean;
29 readonly terminalFontSize?: number;18 readonly terminalFontSize?: number;
30}19}
3120
32const CachedShellSnapshotSchema = Schema.Struct({
33 schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
34 environmentId: EnvironmentId,
35 snapshotReceivedAt: Schema.String,
36 snapshot: OrchestrationShellSnapshot,
37});
38const decodeCachedShellSnapshot = Schema.decodeUnknownOption(CachedShellSnapshotSchema);
39
40async function readStorageItem(key: string): Promise<string | null> {21async 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}
6041
61function cachedShellSnapshotFileName(environmentId: EnvironmentId): string {
62 return `${encodeURIComponent(environmentId)}.json`;
63}
64
65async 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
72export 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
95export 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
119export 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
132export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> {42export 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}
17181
172export async function loadPreferences(): Promise<MobilePreferences> {82export 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}
192102
193export async function savePreferencesPatch(103export 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 @@
1import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts";1import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts";
2import type {2import 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";
14import { formatDuration } from "@t3tools/shared/orchestrationTiming";11import { formatDuration } from "@t3tools/shared/orchestrationTiming";
1512
16import type { DraftComposerImageAttachment } from "./composerImages";13import type { QueuedThreadMessage } from "../state/thread-outbox-model";
17import * as Arr from "effect/Array";14import * as Arr from "effect/Array";
18import * as Order from "effect/Order";15import * as Order from "effect/Order";
1916
...@@ -35,16 +32,6 @@ export interface PendingUserInputDraftAnswer {...@@ -35,16 +32,6 @@ export interface PendingUserInputDraftAnswer {
35 readonly customAnswer?: string;32 readonly customAnswer?: string;
36}33}
3734
38export 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
48export interface ThreadFeedActivity {35export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
3import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
4import { AsyncResult, Atom } from "effect/unstable/reactivity";
5
6import { connectionAtomRuntime } from "../connection/runtime";
7import { usePreparedConnection } from "./session";
8
9export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
10
11const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
12 Atom.withLabel("mobile-asset-url:empty"),
13);
14
15export 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 @@
1import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime);
apps/mobile/src/state/entities.ts created+59
...@@ -0,0 +1,59 @@
1import { useAtomValue } from "@effect/atom-react";
2import type {
3 EnvironmentProject,
4 EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";
6import type {
7 EnvironmentId,
8 ScopedProjectRef,
9 ScopedThreadRef,
10 ServerConfig,
11} from "@t3tools/contracts";
12import { Atom } from "effect/unstable/reactivity";
13
14import { environmentProjects } from "./projects";
15import { environmentServerConfigsAtom } from "./server";
16import { environmentSession } from "./session";
17import { environmentThreadShells } from "./threads";
18
19const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
20 Atom.withLabel("mobile-project:empty"),
21);
22const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).pipe(
23 Atom.withLabel("mobile-thread-shell:empty"),
24);
25const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(
26 Atom.withLabel("mobile-server-config:empty"),
27);
28
29export function useProjects(): ReadonlyArray<EnvironmentProject> {
30 return useAtomValue(environmentProjects.projectsAtom);
31}
32
33export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
34 return useAtomValue(environmentThreadShells.threadShellsAtom);
35}
36
37export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null {
38 return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref));
39}
40
41export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null {
42 return useAtomValue(
43 ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref),
44 );
45}
46
47export 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
57export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {
58 return useAtomValue(environmentServerConfigsAtom);
59}
apps/mobile/src/state/environment-session-registry.ts deleted-48
...@@ -1,48 +0,0 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3import type { EnvironmentSession } from "./remote-runtime-types";
4
5const environmentSessions = new Map<EnvironmentId, EnvironmentSession>();
6const environmentConnectionListeners = new Set<() => void>();
7
8export function getEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null {
9 return environmentSessions.get(environmentId) ?? null;
10}
11
12export function getEnvironmentClient(environmentId: EnvironmentId) {
13 return getEnvironmentSession(environmentId)?.client ?? null;
14}
15
16export function setEnvironmentSession(
17 environmentId: EnvironmentId,
18 session: EnvironmentSession,
19): void {
20 environmentSessions.set(environmentId, session);
21}
22
23export function removeEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null {
24 const session = getEnvironmentSession(environmentId);
25 environmentSessions.delete(environmentId);
26 return session;
27}
28
29export function drainEnvironmentSessions(): ReadonlyArray<EnvironmentSession> {
30 const sessions = [...environmentSessions.values()];
31 environmentSessions.clear();
32 return sessions;
33}
34
35export 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 */
43export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 connectionCatalogDisplayUrl,
4 type EnvironmentPresentation as BaseEnvironmentPresentation,
5} from "@t3tools/client-runtime/connection";
6import type { EnvironmentId } from "@t3tools/contracts";
7import { useMemo } from "react";
8
9import { environmentCatalog } from "../connection/catalog";
10import { environmentPresentations } from "./presentation";
11import { useEnvironmentQuery } from "./query";
12
13export interface EnvironmentPresentation extends BaseEnvironmentPresentation {
14 readonly environmentId: EnvironmentId;
15 readonly label: string;
16 readonly displayUrl: string | null;
17 readonly relayManaged: boolean;
18}
19
20export 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
33export 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
54export function useEnvironmentConnectionState(environmentId: EnvironmentId) {
55 return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId));
56}
apps/mobile/src/state/filesystem.ts created+5
...@@ -0,0 +1,5 @@
1import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime);
apps/mobile/src/state/git.ts created+5
...@@ -0,0 +1,5 @@
1import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime);
apps/mobile/src/state/orchestration.ts created+5
...@@ -0,0 +1,5 @@
1import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime);
apps/mobile/src/state/presentation.ts created+31
...@@ -0,0 +1,31 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection";
3import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation";
4import type { EnvironmentId } from "@t3tools/contracts";
5import { Atom } from "effect/unstable/reactivity";
6
7import { environmentCatalog } from "../connection/catalog";
8import { environmentSession } from "./session";
9
10export const environmentPresentations = createEnvironmentPresentationAtoms({
11 catalogValueAtom: environmentCatalog.catalogValueAtom,
12 stateAtom: environmentCatalog.stateAtom,
13 configValueAtom: environmentSession.configValueAtom,
14});
15
16const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make<EnvironmentPresentation | null>(null).pipe(
17 Atom.withLabel("mobile-environment-presentation:empty"),
18);
19
20export 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 @@
1import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects";
2import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects";
3
4import { environmentCatalog } from "../connection/catalog";
5import { connectionAtomRuntime } from "../connection/runtime";
6import { environmentSnapshotAtom } from "./shell";
7
8export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime);
9export const environmentProjects = createEnvironmentProjectAtoms({
10 catalogValueAtom: environmentCatalog.catalogValueAtom,
11 snapshotAtom: environmentSnapshotAtom,
12});
apps/mobile/src/state/queries.test.ts created+62
...@@ -0,0 +1,62 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3
4import { buildCheckpointDiffTargets, normalizeComposerPathSearchQuery } from "./queryTargets";
5
6describe("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 @@
1import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import { useEffect, useMemo, useState } from "react";
4
5import { orchestrationEnvironment } from "./orchestration";
6import { projectEnvironment } from "./projects";
7import { useEnvironmentQuery } from "./query";
8import { useEnvironmentThread } from "./threads";
9import { vcsEnvironment } from "./vcs";
10import {
11 buildCheckpointDiffTargets,
12 normalizeComposerPathSearchQuery,
13 type CheckpointDiffTarget,
14} from "./queryTargets";
15
16const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200;
17const COMPOSER_PATH_SEARCH_LIMIT = 20;
18const VCS_REF_LIST_LIMIT = 100;
19
20export interface ThreadDetailView {
21 readonly data: OrchestrationThread | null;
22 readonly error: string | null;
23 readonly isPending: boolean;
24 readonly isDeleted: boolean;
25}
26
27export interface ComposerPathSearchTarget {
28 readonly environmentId: EnvironmentId | null;
29 readonly cwd: string | null;
30 readonly query: string | null;
31}
32
33function 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
48export 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
61export 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
81export 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
114export 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 @@
1import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
2import * as Cause from "effect/Cause";
3import * as Option from "effect/Option";
4import { AsyncResult, Atom } from "effect/unstable/reactivity";
5
6const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
7 Atom.withLabel("mobile-environment-query:empty"),
8);
9
10export interface EnvironmentQueryView<A> {
11 readonly data: A | null;
12 readonly error: string | null;
13 readonly isPending: boolean;
14 readonly refresh: () => void;
15}
16
17function 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
24export 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 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2
3export 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
11export function normalizeComposerPathSearchQuery(query: string | null): string {
12 return query?.trim() ?? "";
13}
14
15export 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 @@
1import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/state/relay";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const relayEnvironmentDiscovery =
6 createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime);
apps/mobile/src/state/remote-runtime-types.ts+10-13
...@@ -1,27 +1,24 @@...@@ -1,27 +1,24 @@
1import type {1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2 EnvironmentConnection,2import { EnvironmentId, ThreadId, type ServerConfig } from "@t3tools/contracts";
3 EnvironmentConnectionState,
4 WsRpcClient,
5} from "@t3tools/client-runtime";
6import { EnvironmentId, ThreadId } from "@t3tools/contracts";
73
8export type { EnvironmentRuntimeState } from "@t3tools/client-runtime";4export interface EnvironmentRuntimeState {
5 readonly connectionState: EnvironmentConnectionPhase;
6 readonly connectionError: string | null;
7 readonly connectionErrorTraceId: string | null;
8 readonly serverConfig: ServerConfig | null;
9}
910
10export interface ConnectedEnvironmentSummary {11export 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}
1820
19export interface SelectedThreadRef {21export interface SelectedThreadRef {
20 readonly environmentId: EnvironmentId;22 readonly environmentId: EnvironmentId;
21 readonly threadId: ThreadId;23 readonly threadId: ThreadId;
22}24}
23
24export interface EnvironmentSession {
25 readonly client: WsRpcClient;
26 readonly connection: EnvironmentConnection;
27}
apps/mobile/src/state/review.ts created+5
...@@ -0,0 +1,5 @@
1import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime);
apps/mobile/src/state/server.ts created+14
...@@ -0,0 +1,14 @@
1import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";
2import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";
3
4import { environmentCatalog } from "../connection/catalog";
5import { connectionAtomRuntime } from "../connection/runtime";
6import { environmentSession } from "./session";
7
8export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
9 initialConfigValueAtom: environmentSession.configValueAtom,
10});
11export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
12 catalogValueAtom: environmentCatalog.catalogValueAtom,
13 configValueAtom: serverEnvironment.configValueAtom,
14});
apps/mobile/src/state/session.ts created+21
...@@ -0,0 +1,21 @@
1import { useAtomValue } from "@effect/atom-react";
2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
3import type { EnvironmentId } from "@t3tools/contracts";
4import * as Option from "effect/Option";
5import { Atom } from "effect/unstable/reactivity";
6
7import { connectionAtomRuntime } from "../connection/runtime";
8
9export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime);
10
11const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe(
12 Atom.withLabel("mobile-prepared-connection:empty"),
13);
14
15export 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 @@
1import {
2 createEnvironmentShellAtoms,
3 createEnvironmentShellSummaryAtom,
4 createEnvironmentSnapshotAtom,
5 createShellEnvironmentAtoms,
6} from "@t3tools/client-runtime/state/shell";
7
8import { environmentCatalog } from "../connection/catalog";
9import { connectionAtomRuntime } from "../connection/runtime";
10
11export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime);
12export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime);
13export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom);
14export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({
15 catalogValueAtom: environmentCatalog.catalogValueAtom,
16 shellStateValueAtom: environmentShell.stateValueAtom,
17});
apps/mobile/src/state/sourceControl.ts created+5
...@@ -0,0 +1,5 @@
1import { createSourceControlEnvironmentAtoms } from "@t3tools/client-runtime/state/source-control";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const sourceControlEnvironment = createSourceControlEnvironmentAtoms(connectionAtomRuntime);
apps/mobile/src/state/terminal.ts created+5
...@@ -0,0 +1,5 @@
1import { createTerminalEnvironmentAtoms } from "@t3tools/client-runtime/state/terminal";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const terminalEnvironment = createTerminalEnvironmentAtoms(connectionAtomRuntime);
apps/mobile/src/state/thread-outbox-manager.ts created+108
...@@ -0,0 +1,108 @@
1import type { EnvironmentId, MessageId } from "@t3tools/contracts";
2import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
3
4import {
5 flattenQueuedThreadMessages,
6 groupQueuedThreadMessages,
7 type QueuedThreadMessage,
8} from "./thread-outbox-model";
9import type { ThreadOutboxStorage } from "./thread-outbox-storage";
10
11export interface ThreadOutboxManagerOptions {
12 readonly registry: AtomRegistry.AtomRegistry;
13 readonly storage: ThreadOutboxStorage;
14 readonly warn?: (message: string, error: unknown) => void;
15}
16
17export 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 @@
1import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";
2import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
3import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts";
4import * as Schema from "effect/Schema";
5
6import type { DraftComposerImageAttachment } from "../lib/composerImages";
7import { scopedThreadKey } from "../lib/scopedEntities";
8
9const THREAD_OUTBOX_SCHEMA_VERSION = 1;
10const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;
11
12const 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
22export 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
33const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema);
34const encodeStoredQueuedThreadMessage = Schema.encodeUnknownSync(QueuedThreadMessageSchema);
35
36export 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
46export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown {
47 return encodeStoredQueuedThreadMessage({
48 schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION,
49 ...message,
50 });
51}
52
53export function decodeQueuedThreadMessage(value: unknown): QueuedThreadMessage {
54 const { schemaVersion: _, ...message } = decodeStoredQueuedThreadMessage(value);
55 return message;
56}
57
58export 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
77export function flattenQueuedThreadMessages(
78 queues: Record<string, ReadonlyArray<QueuedThreadMessage>>,
79): ReadonlyArray<QueuedThreadMessage> {
80 return Object.values(queues).flat();
81}
82
83export 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
87export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send";
88
89export 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
101function 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
111export 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 @@
1import type { MessageId } from "@t3tools/contracts";
2
3import {
4 decodeQueuedThreadMessage,
5 encodeQueuedThreadMessage,
6 type QueuedThreadMessage,
7} from "./thread-outbox-model";
8
9const THREAD_OUTBOX_DIRECTORY = "thread-outbox";
10
11export interface ThreadOutboxStorage {
12 readonly load: () => Promise<ReadonlyArray<QueuedThreadMessage>>;
13 readonly write: (message: QueuedThreadMessage) => Promise<void>;
14 readonly remove: (message: QueuedThreadMessage) => Promise<void>;
15}
16
17function messageFileName(messageId: MessageId): string {
18 return `${encodeURIComponent(messageId)}.json`;
19}
20
21async 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
28async function getMessageFile(messageId: MessageId) {
29 const { File } = await import("expo-file-system");
30 return new File(await getOutboxDirectory(), messageFileName(messageId));
31}
32
33export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
3import { AtomRegistry } from "effect/unstable/reactivity";
4
5import {
6 decodeQueuedThreadMessage,
7 groupQueuedThreadMessages,
8 resolveThreadOutboxDeliveryAction,
9 shouldRetryThreadOutboxDelivery,
10 threadOutboxRetryDelayMs,
11 type QueuedThreadMessage,
12} from "./thread-outbox-model";
13import { createThreadOutboxManager } from "./thread-outbox-manager";
14import type { ThreadOutboxStorage } from "./thread-outbox-storage";
15
16function 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
33describe("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 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3import { appAtomRegistry } from "./atom-registry";
4import { createThreadOutboxManager } from "./thread-outbox-manager";
5import type { QueuedThreadMessage } from "./thread-outbox-model";
6import { expoThreadOutboxStorage } from "./thread-outbox-storage";
7
8export * from "./thread-outbox-model";
9
10export const threadOutboxManager = createThreadOutboxManager({
11 registry: appAtomRegistry,
12 storage: expoThreadOutboxStorage,
13});
14
15export function ensureThreadOutboxLoaded(): void {
16 void threadOutboxManager.load();
17}
18
19export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> {
20 return threadOutboxManager.enqueue(message);
21}
22
23export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> {
24 return threadOutboxManager.remove(message);
25}
26
27export function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise<void> {
28 return threadOutboxManager.clearEnvironment(environmentId);
29}
apps/mobile/src/state/threads.ts created+45
...@@ -0,0 +1,45 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 createEnvironmentThreadDetailAtoms,
4 createEnvironmentThreadShellAtoms,
5 createEnvironmentThreadStateAtoms,
6 EMPTY_ENVIRONMENT_THREAD_STATE,
7 type EnvironmentThreadState,
8 createThreadEnvironmentAtoms,
9} from "@t3tools/client-runtime/state/threads";
10import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
11import * as Option from "effect/Option";
12import { AsyncResult, Atom } from "effect/unstable/reactivity";
13
14import { environmentCatalog } from "../connection/catalog";
15import { connectionAtomRuntime } from "../connection/runtime";
16import { environmentSnapshotAtom } from "./shell";
17
18export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime);
19export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime);
20export const environmentThreadDetails = createEnvironmentThreadDetailAtoms(
21 environmentThreads.stateAtom,
22);
23export const environmentThreadShells = createEnvironmentThreadShellAtoms({
24 catalogValueAtom: environmentCatalog.catalogValueAtom,
25 snapshotAtom: environmentSnapshotAtom,
26});
27
28const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe(
29 Atom.withLabel("mobile-environment-thread:empty"),
30);
31
32export 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 @@
1import { RegistryContext } from "@effect/atom-react";
2import {
3 type AtomCommand,
4 type AtomCommandOptions,
5 type AtomCommandResult,
6 runAtomCommand,
7} from "@t3tools/client-runtime/state/runtime";
8import { useCallback, useContext } from "react";
9
10export 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 @@
1import { RegistryContext } from "@effect/atom-react";
2import {
3 executeAtomQuery,
4 type AtomCommandOptions,
5 type AtomCommandResult,
6} from "@t3tools/client-runtime/state/runtime";
7import { AsyncResult, type Atom } from "effect/unstable/reactivity";
8import { useCallback, useContext } from "react";
9
10export 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 @@
1import { createCheckpointDiffManager, type CheckpointDiffTarget } from "@t3tools/client-runtime";
2
3import { appAtomRegistry } from "./atom-registry";
4import { getEnvironmentClient } from "./environment-session-registry";
5
6export const checkpointDiffManager = createCheckpointDiffManager({
7 getRegistry: () => appAtomRegistry,
8 getClient: (environmentId) => getEnvironmentClient(environmentId)?.orchestration ?? null,
9});
10
11export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
3
4import { type ComposerDraft, removeComposerDraftsForEnvironment } from "./use-composer-drafts";
5
6const DRAFT: ComposerDraft = {
7 text: "hello",
8 attachments: [],
9};
10
11describe("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 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
2import { useEffect } from "react";3import { useEffect } from "react";
3import { Atom } from "effect/unstable/reactivity";4import { Atom } from "effect/unstable/reactivity";
45
...@@ -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);
3233
33let loadStarted = false;34let loadPromise: Promise<void> | null = null;
34let persistTimer: ReturnType<typeof setTimeout> | null = null;35let persistTimer: ReturnType<typeof setTimeout> | null = null;
3536
36function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {37function 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}
8182
83async 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
82async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> {98async 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}
110115
111export function ensureComposerDraftsLoaded(): void {116export 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}
127135
128function updateComposerDrafts(136function 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}
236244
245export 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
255export 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
237export function useComposerDraft(draftKey: string | null): ComposerDraft {274export 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 @@
1import { useAtomValue } from "@effect/atom-react";1import { type ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads";
2import {
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";
12import { useEffect, useMemo } from "react";
132
14import { appAtomRegistry } from "./atom-registry";3import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries";
15import {
16 getEnvironmentClient,
17 subscribeEnvironmentConnections,
18} from "./environment-session-registry";
194
20const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000;5export function useComposerPathSearch(target: ComposerPathSearchTarget) {
216 return useComposerPathSearchQuery(target);
22const 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
29export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 EMPTY_ENVIRONMENT_RUNTIME_ATOM,
4 EMPTY_ENVIRONMENT_RUNTIME_STATE,
5 createEnvironmentRuntimeManager,
6 environmentRuntimeStateAtom,
7 getEnvironmentRuntimeTargetKey,
8 type EnvironmentRuntimeState,
9} from "@t3tools/client-runtime";
10import type { EnvironmentId } from "@t3tools/contracts";
11import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
12
13import { appAtomRegistry } from "./atom-registry";
14import * as Arr from "effect/Array";
15import * as Order from "effect/Order";
16
17export const environmentRuntimeManager = createEnvironmentRuntimeManager({
18 getRegistry: () => appAtomRegistry,
19});
20
21export 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
31export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
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";
12import type {
13 EnvironmentId,
14 FilesystemBrowseInput,
15 FilesystemBrowseResult,
16} from "@t3tools/contracts";
17import { useEffect, useMemo } from "react";
18
19import { appAtomRegistry } from "./atom-registry";
20import {
21 getEnvironmentClient,
22 subscribeEnvironmentConnections,
23} from "./environment-session-registry";
24
25const filesystemBrowseManager = createFilesystemBrowseManager<EnvironmentId>({
26 getRegistry: () => appAtomRegistry,
27 getClient: (environmentId) => getEnvironmentClient(environmentId)?.filesystem ?? null,
28 subscribeClientChanges: subscribeEnvironmentConnections,
29});
30
31function filesystemBrowseTargetForEnvironment(
32 environmentId: EnvironmentId | null,
33 input: FilesystemBrowseInput | null,
34): FilesystemBrowseTarget<EnvironmentId> {
35 return { key: environmentId, input };
36}
37
38export 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
49export function invalidateFilesystemBrowseForEnvironment(
50 environmentId: EnvironmentId | null,
51 input: FilesystemBrowseInput | null,
52): void {
53 filesystemBrowseManager.invalidate(filesystemBrowseTargetForEnvironment(environmentId, input));
54}
55
56export function resetFilesystemBrowseState(): void {
57 filesystemBrowseManager.reset();
58}
59
60export function resetFilesystemBrowseStateForTests(): void {
61 resetFilesystemBrowseState();
62}
63
64export 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 @@
1import { useMemo } from "react";
2import * as Order from "effect/Order";
3import * as Arr from "effect/Array";
4
5import {
6 EnvironmentConnectionState,
7 EnvironmentScopedProjectShell,
8 EnvironmentScopedThreadShell,
9 scopeProjectShell,
10 scopeThreadShell,
11} from "@t3tools/client-runtime";
12
13import { ConnectedEnvironmentSummary } from "./remote-runtime-types";
14import type { SavedRemoteConnection } from "../lib/connection";
15import { useCachedShellSnapshotMetadata, useShellSnapshotStates } from "./use-shell-snapshot";
16import {
17 useRemoteConnectionStatus,
18 useRemoteEnvironmentState,
19} from "./use-remote-environment-registry";
20
21const 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
32const 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
43function 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
61function 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
71export 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
85export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
3import {
4 createManagedRelaySession,
5 ManagedRelayDpopSigner,
6 setManagedRelaySession,
7} from "@t3tools/client-runtime";
8import * as Effect from "effect/Effect";
9import { beforeEach, vi } from "vite-plus/test";
10
11const 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
65vi.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
75vi.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
89vi.mock("../lib/connection", async (importOriginal) => ({
90 ...(await importOriginal<typeof import("../lib/connection")>()),
91 bootstrapRemoteConnection: mocks.bootstrapRemoteConnection,
92}));
93
94vi.mock("../features/cloud/linkEnvironment", () => ({
95 refreshCloudEnvironmentConnection: mocks.refreshCloudEnvironmentConnection,
96}));
97
98vi.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
107vi.mock("../lib/runtime", () => ({
108 mobileRuntime: {
109 runPromise: mocks.mobileRunPromise,
110 },
111}));
112
113vi.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
121vi.mock("../features/agent-awareness/remoteRegistration", () => ({
122 registerAgentAwarenessConnection: mocks.registerAgentAwarenessConnection,
123 unregisterAgentAwarenessConnection: mocks.unregisterAgentAwarenessConnection,
124 unregisterAllAgentAwarenessConnections: vi.fn(),
125}));
126
127vi.mock("../features/terminal/terminalDebugLog", () => ({
128 terminalDebugLog: mocks.terminalDebugLog,
129}));
130
131vi.mock("./use-environment-runtime", () => ({
132 environmentRuntimeManager: {
133 invalidate: mocks.environmentRuntimeInvalidate,
134 patch: mocks.environmentRuntimePatch,
135 },
136 useEnvironmentRuntimeStates: vi.fn(() => ({})),
137}));
138
139vi.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
151vi.mock("./use-source-control-discovery", () => ({
152 invalidateSourceControlDiscoveryForEnvironment:
153 mocks.invalidateSourceControlDiscoveryForEnvironment,
154 resetSourceControlDiscoveryState: vi.fn(),
155}));
156
157vi.mock("./use-terminal-session", () => ({
158 subscribeTerminalMetadata: mocks.subscribeTerminalMetadata,
159 terminalSessionManager: {
160 invalidate: vi.fn(),
161 invalidateEnvironment: mocks.terminalSessionInvalidateEnvironment,
162 },
163}));
164
165import {
166 connectSavedEnvironment,
167 disconnectEnvironment,
168 reconnectEnvironmentConnectionsAfterAppResume,
169} from "./use-remote-environment-registry";
170import { appAtomRegistry } from "./atom-registry";
171
172const environmentId = EnvironmentId.make("env-mobile-test");
173
174const 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
184describe("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 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useEffect, useMemo } from "react";2import type { PreparedConnection } from "@t3tools/client-runtime/connection";
3import { Alert, AppState } from "react-native";
4
5import {
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";
19import type { EnvironmentId } from "@t3tools/contracts";3import type { EnvironmentId } from "@t3tools/contracts";
20import * as Arr from "effect/Array";4import type { ServerConfig } from "@t3tools/contracts";
21import * as Duration from "effect/Duration";5import * as Cause from "effect/Cause";
22import * as Effect from "effect/Effect";
23import * as Order from "effect/Order";
24import * as Option from "effect/Option";6import * as Option from "effect/Option";
25import { pipe } from "effect/Function";7import { AsyncResult, Atom } from "effect/unstable/reactivity";
26import { Atom } from "effect/unstable/reactivity";8import { useCallback, useMemo } from "react";
27import {9import { Alert } from "react-native";
28 type SavedRemoteConnection,10
29 bootstrapRemoteConnection,11import { useEnvironmentServerConfig } from "../state/entities";
30 isRelayManagedConnection,12import { useConnectionController } from "../features/connection/useConnectionController";
31 toStableSavedRemoteConnection,13import { environmentPresentations, useEnvironmentPresentation } from "./presentation";
32} from "../lib/connection";
33import { refreshCloudEnvironmentConnection } from "../features/cloud/linkEnvironment";
34import { terminalDebugLog } from "../features/terminal/terminalDebugLog";
35import {14import {
36 clearCachedShellSnapshot,15 projectEnvironmentPresentation,
37 clearSavedConnection,16 type EnvironmentPresentation,
38 loadCachedShellSnapshot,17} from "../state/environments";
39 loadSavedConnections,18import { useWorkspaceState } from "../state/workspace";
40 saveCachedShellSnapshot,19import type { SavedRemoteConnection } from "../lib/connection";
41 saveConnection,
42} from "../lib/storage";
43import { appAtomRegistry } from "./atom-registry";20import { appAtomRegistry } from "./atom-registry";
44import { mobileRuntime } from "../lib/runtime";21import type { ConnectedEnvironmentSummary, EnvironmentRuntimeState } from "./remote-runtime-types";
45import {22import { environmentSession, usePreparedConnection } from "./session";
46 drainEnvironmentSessions,23import { environmentCatalog } from "../connection/catalog";
47 getEnvironmentSession,
48 notifyEnvironmentConnectionListeners,
49 removeEnvironmentSession,
50 setEnvironmentSession,
51} from "./environment-session-registry";
52import { type ConnectedEnvironmentSummary } from "./remote-runtime-types";
53import {
54 invalidateSourceControlDiscoveryForEnvironment,
55 resetSourceControlDiscoveryState,
56} from "./use-source-control-discovery";
57import {
58 registerAgentAwarenessConnection,
59 unregisterAgentAwarenessConnection,
60 unregisterAllAgentAwarenessConnections,
61} from "../features/agent-awareness/remoteRegistration";
62import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";
63import {
64 clearCachedShellSnapshotMetadata,
65 hydrateCachedShellSnapshot,
66 markShellSnapshotLive,
67 shellSnapshotManager,
68} from "./use-shell-snapshot";
69import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session";
70
71const terminalMetadataUnsubscribers = new Map<EnvironmentId, () => void>();
72const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry();
73const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000;
74const APP_RESUME_RECONNECT_COOLDOWN_MS = 2_000;
75let lastAppResumeReconnectAt = Number.NEGATIVE_INFINITY;
76
77interface RemoteEnvironmentLocalState {
78 readonly isLoadingSavedConnection: boolean;
79 readonly connectionPairingUrl: string;
80 readonly pendingConnectionError: string | null;
81 readonly savedConnectionsById: Record<EnvironmentId, SavedRemoteConnection>;
82}
83
84const isLoadingSavedConnectionAtom = Atom.make(true).pipe(
85 Atom.keepAlive,
86 Atom.withLabel("mobile:is-loading-saved-connection"),
87);
8824
89const connectionPairingUrlAtom = Atom.make("").pipe(25const 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);
9834
99const savedConnectionsByIdAtom = Atom.make<Record<EnvironmentId, SavedRemoteConnection>>({}).pipe(
100 Atom.keepAlive,
101 Atom.withLabel("mobile:saved-connections"),
102);
103
104function getSavedConnectionsById(): Record<EnvironmentId, SavedRemoteConnection> {
105 return appAtomRegistry.get(savedConnectionsByIdAtom);
106}
107
108function setIsLoadingSavedConnection(value: boolean): void {
109 appAtomRegistry.set(isLoadingSavedConnectionAtom, value);
110}
111
112function setConnectionPairingUrl(pairingUrl: string): void {
113 appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl);
114}
115
116function clearConnectionPairingUrl(): void {
117 appAtomRegistry.set(connectionPairingUrlAtom, "");
118}
119
120export function setPendingConnectionError(message: string | null): void {35export function setPendingConnectionError(message: string | null): void {
121 appAtomRegistry.set(pendingConnectionErrorAtom, message);36 appAtomRegistry.set(pendingConnectionErrorAtom, message);
122}37}
12338
124function clearPendingConnectionError(): void {39function 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;
12754
128function replaceSavedConnections(connections: Record<EnvironmentId, SavedRemoteConnection>): void {55 return {
129 appAtomRegistry.set(savedConnectionsByIdAtom, connections);56 environmentId: environment.environmentId,
130}57 environmentLabel: environment.label,
13158 pairingUrl: displayUrl,
132function 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}
13972
140function removeSavedConnection(environmentId: EnvironmentId): void {73const 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
86function 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}
14697
147function useRemoteEnvironmentLocalState(): RemoteEnvironmentLocalState {98export 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);
152101
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
164function 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
176function fromPromise<T>(tryPromise: () => Promise<T>): Effect.Effect<T, unknown> {
177 return Effect.tryPromise({
178 try: tryPromise,
179 catch: (cause) => cause,
180 });
181}
182
183export 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
225export 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}
461107
462export function reconnectEnvironmentConnectionsAfterAppResume(reason: string): void {108export 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 }
467116 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
514function 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
527const environmentsSortOrder = Order.mapInput(
528 Order.Struct({
529 environmentLabel: Order.String,
530 }),
531 (environment: ConnectedEnvironmentSummary) => ({
532 environmentLabel: environment.environmentLabel,
533 }),
534);
535
536function 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
556export 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}
627118
628export function useRemoteEnvironmentState() {119export 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);
633124 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}
642129
643export function useRemoteConnectionStatus() {130export function useRemoteConnectionStatus() {
644 const { environmentStateById, pendingConnectionError, savedConnectionsById } =131 const workspace = useWorkspaceState();
645 useRemoteEnvironmentState();132 const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom);
646133 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 );
682146
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}
689153
690export function useRemoteConnections() {154export 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();
693159
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 );
711181
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 }
721202 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 {
727208 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 );
733219
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 @@
1import { useCallback } from "react";
2
3import {
4 CommandId,
5 type ModelSelection,
6 type ProviderInteractionMode,
7 type RuntimeMode,
8} from "@t3tools/contracts";
9
10import { uuidv4 } from "../lib/uuid";
11import { environmentRuntimeManager } from "./use-environment-runtime";
12import { getEnvironmentClient } from "./environment-session-registry";
13import { useRemoteEnvironmentState } from "./use-remote-environment-registry";
14import { useThreadSelection } from "./use-thread-selection";
15
16export 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 @@
1import { useCallback, useEffect } from "react";1import { useCallback, useEffect, useMemo } from "react";
22
3import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
3import {5import {
4 EnvironmentScopedProjectShell,
5 EnvironmentScopedThreadShell,
6 type VcsRef,
7 type GitActionRequestInput,6 type GitActionRequestInput,
8} from "@t3tools/client-runtime";7 type VcsActionOperation,
9import { CommandId, type GitRunStackedActionResult } from "@t3tools/contracts";8 type VcsRef,
9} from "@t3tools/client-runtime/state/vcs";
10import type { GitRunStackedActionResult } from "@t3tools/contracts";
10import {11import {
11 dedupeRemoteBranchesWithLocalMatches,12 dedupeRemoteBranchesWithLocalMatches,
12 sanitizeFeatureBranchName,13 sanitizeFeatureBranchName,
13} from "@t3tools/shared/git";14} from "@t3tools/shared/git";
15import * as Cause from "effect/Cause";
16import { AsyncResult } from "effect/unstable/reactivity";
1417
18import { useBranches } from "../state/queries";
19import { threadEnvironment } from "../state/threads";
20import { vcsActionManager, vcsEnvironment } from "../state/vcs";
15import { uuidv4 } from "../lib/uuid";21import { uuidv4 } from "../lib/uuid";
16import { getEnvironmentClient } from "./environment-session-registry";22import { appAtomRegistry } from "./atom-registry";
17import { setPendingConnectionError } from "./use-remote-environment-registry";23import { setPendingConnectionError } from "./use-remote-environment-registry";
18import { vcsActionManager, showGitActionResult } from "./use-vcs-action-state";24import { useAtomCommand } from "./use-atom-command";
19import { vcsRefManager } from "./use-vcs-refs";25import { showGitActionResult } from "./use-vcs-action-state";
20import { vcsStatusManager } from "./use-vcs-status";
21import { useThreadSelection } from "./use-thread-selection";26import { useThreadSelection } from "./use-thread-selection";
22import { useSelectedThreadWorktree } from "./use-selected-thread-worktree";27import { useSelectedThreadWorktree } from "./use-selected-thread-worktree";
2328
24export function useSelectedThreadGitActions() {29export 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 );
2747
28 const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null;48 const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null;
2949 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,
4270 ...(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 );
5377
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 }
6488
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 },
7094 });
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 );
86117
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]);
94124
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 }
106139
107 const cwd = selectedThreadCwd;140 const target = {
108 if (!cwd) {141 environmentId: selectedThread.environmentId,
109 return null;142 cwd: selectedThreadCwd,
110 }143 };
111144 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 );
128166
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,
133171 );
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]);
155173
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)) {
169186 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 }
179189 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 );
189195
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 );
209228
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 );
232261
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 },
247276 });
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 );
261294
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]);
277319
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 });
307337
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 }
319345
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";
22
3import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";3import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
44
5import { useBranches } from "./queries";
6import { useEnvironmentQuery } from "./query";
7import { sourceControlEnvironment } from "./sourceControl";
5import { useVcsActionState } from "./use-vcs-action-state";8import { useVcsActionState } from "./use-vcs-action-state";
6import { useVcsRefs } from "./use-vcs-refs";
7import { useSourceControlDiscovery } from "./use-source-control-discovery";
8import { useThreadSelection } from "./use-thread-selection";9import { useThreadSelection } from "./use-thread-selection";
9import { useSelectedThreadWorktree } from "./use-selected-thread-worktree";10import { useSelectedThreadWorktree } from "./use-selected-thread-worktree";
1011
...@@ -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 );
2432
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 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useMemo, useState } from "react";2import { useCallback, useMemo, useState } from "react";
33
4import { ApprovalRequestId, CommandId, type ProviderApprovalDecision } from "@t3tools/contracts";4import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";
5import { Atom } from "effect/unstable/reactivity";5import { Atom } from "effect/unstable/reactivity";
66
7import { threadEnvironment } from "../state/threads";
7import { scopedRequestKey } from "../lib/scopedEntities";8import { scopedRequestKey } from "../lib/scopedEntities";
8import {9import {
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";
15import { uuidv4 } from "../lib/uuid";
16import { appAtomRegistry } from "./atom-registry";16import { appAtomRegistry } from "./atom-registry";
17import { getEnvironmentClient } from "./environment-session-registry";
18import { useSelectedThreadDetail } from "./use-thread-detail";17import { useSelectedThreadDetail } from "./use-thread-detail";
19import { useThreadSelection } from "./use-thread-selection";18import { useThreadSelection } from "./use-thread-selection";
19import { useAtomCommand } from "./use-atom-command";
2020
21const userInputDraftsByRequestKeyAtom = Atom.make<21const 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}
5555
56export function useSelectedThreadRequests() {56export 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 }
114122
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 );
136137
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 }
141142
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 ]);
163162
164 return {163 return {
165 activePendingApproval,164 activePendingApproval,
apps/mobile/src/state/use-shell-snapshot.ts deleted-111
...@@ -1,111 +0,0 @@
1import * as Arr from "effect/Array";
2import * as Order from "effect/Order";
3import { useAtomValue } from "@effect/atom-react";
4import { Atom } from "effect/unstable/reactivity";
5import {
6 EMPTY_SHELL_SNAPSHOT_ATOM,
7 EMPTY_SHELL_SNAPSHOT_STATE,
8 createShellSnapshotManager,
9 getShellSnapshotTargetKey,
10 shellSnapshotStateAtom,
11 type ShellSnapshotState,
12} from "@t3tools/client-runtime";
13import type { EnvironmentId } from "@t3tools/contracts";
14import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
15
16import { appAtomRegistry } from "./atom-registry";
17import type { CachedShellSnapshot } from "../lib/storage";
18
19const cachedShellSnapshotMetadataAtom = Atom.make<
20 Readonly<Record<EnvironmentId, { readonly snapshotReceivedAt: string }>>
21>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:cached-shell-snapshot-metadata"));
22
23export const shellSnapshotManager = createShellSnapshotManager({
24 getRegistry: () => appAtomRegistry,
25});
26
27export 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
37export 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
48export function clearCachedShellSnapshotMetadata(environmentId: EnvironmentId): void {
49 markShellSnapshotLive(environmentId);
50}
51
52export function useCachedShellSnapshotMetadata(): Readonly<
53 Record<EnvironmentId, { readonly snapshotReceivedAt: string }>
54> {
55 return useAtomValue(cachedShellSnapshotMetadataAtom);
56}
57
58export 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
66export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
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";
12import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts";
13import { useEffect, useMemo } from "react";
14
15import { appAtomRegistry } from "./atom-registry";
16import {
17 getEnvironmentClient,
18 subscribeEnvironmentConnections,
19} from "./environment-session-registry";
20
21const sourceControlDiscoveryManager = createSourceControlDiscoveryManager<EnvironmentId>({
22 getRegistry: () => appAtomRegistry,
23 getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null,
24 subscribeClientChanges: subscribeEnvironmentConnections,
25});
26
27function sourceControlDiscoveryTargetForEnvironment(
28 environmentId: EnvironmentId | null,
29): SourceControlDiscoveryTarget<EnvironmentId> {
30 return { key: environmentId ?? null };
31}
32
33export 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
43export function invalidateSourceControlDiscoveryForEnvironment(
44 environmentId: EnvironmentId | null,
45): void {
46 sourceControlDiscoveryManager.invalidate(
47 sourceControlDiscoveryTargetForEnvironment(environmentId),
48 );
49}
50
51export function resetSourceControlDiscoveryState(): void {
52 sourceControlDiscoveryManager.reset();
53}
54
55export function resetSourceControlDiscoveryStateForTests(): void {
56 resetSourceControlDiscoveryState();
57}
58
59export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {1import {
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";
13import type {8import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts";
14 EnvironmentId,
15 TerminalAttachInput,
16 TerminalAttachStreamEvent,
17 TerminalMetadataStreamEvent,
18 TerminalSessionSnapshot,
19} from "@t3tools/contracts";
20import { useMemo } from "react";9import { useMemo } from "react";
2110
22import { appAtomRegistry } from "./atom-registry";11import { useEnvironmentQuery } from "./query";
12import { terminalEnvironment } from "./terminal";
2313
24export const terminalSessionManager = createTerminalSessionManager({14export function useAttachedTerminalSession(input: {
25 getRegistry: () => appAtomRegistry,15 readonly environmentId: EnvironmentId | null;
26});16 readonly terminal: TerminalAttachInput | null;
2717}): TerminalSessionState {
28export 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
42export 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
58export 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(
6427 input.environmentId === null
65export 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}
7549
76export function useKnownTerminalSessions(input: {50export 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 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useEffect, useMemo } from "react";2import { useCallback, useEffect, useMemo } from "react";
33
4import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
5import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts";4import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
6import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";5import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
7import { Atom } from "effect/unstable/reactivity";
86
9import { makeQueuedMessageMetadata } from "../lib/commandMetadata";7import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
10import {8import {
...@@ -14,7 +12,7 @@ import {...@@ -14,7 +12,7 @@ import {
14} from "../lib/composerImages";12} from "../lib/composerImages";
15import type { DraftComposerImageAttachment } from "../lib/composerImages";13import type { DraftComposerImageAttachment } from "../lib/composerImages";
16import { scopedThreadKey } from "../lib/scopedEntities";14import { scopedThreadKey } from "../lib/scopedEntities";
17import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity";15import { buildThreadFeed } from "../lib/threadActivity";
18import { appAtomRegistry } from "../state/atom-registry";16import { appAtomRegistry } from "../state/atom-registry";
19import {17import {
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";
29import { getEnvironmentClient } from "./environment-session-registry";27import { setPendingConnectionError } from "../state/use-remote-environment-registry";
30import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types";
31import {
32 setPendingConnectionError,
33 useRemoteConnectionStatus,
34} from "../state/use-remote-environment-registry";
35import { useRemoteCatalog } from "../state/use-remote-catalog";
36import { useSelectedThreadDetail } from "../state/use-thread-detail";28import { useSelectedThreadDetail } from "../state/use-thread-detail";
37import { useThreadSelection } from "../state/use-thread-selection";29import { useThreadSelection } from "../state/use-thread-selection";
3830import { enqueueThreadOutboxMessage } from "./thread-outbox";
39const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(31import { useThreadOutboxMessages } from "./use-thread-outbox";
40 Atom.keepAlive,32import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox-drain";
41 Atom.withLabel("mobile:thread-composer:dispatching-message-id"),
42);
43
44const queuedMessagesByThreadKeyAtom = Atom.make<Record<string, ReadonlyArray<QueuedThreadMessage>>>(
45 {},
46).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages"));
4733
48export function appendReviewCommentToDraft(input: {34export 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}
7864
79function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {
80 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);
81}
82
83function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void {
84 const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom);
85 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current);
86}
87
88function 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
97function 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
120function 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
177export function useThreadComposerState() {65export 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();
18571
186 useEffect(() => {72 useEffect(() => {
187 ensureComposerDraftsLoaded();73 ensureComposerDraftsLoaded();
...@@ -197,10 +83,14 @@ export function useThreadComposerState() {...@@ -197,10 +83,14 @@ export function useThreadComposerState() {
19783
198 const selectedThreadFeed = useMemo(84 const selectedThreadFeed = useMemo(
199 () =>85 () =>
200 selectedThread86 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 );
20595
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;
210100
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]);
221112
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 ]);
234131
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");
238136
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 }
309149
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]);
322168
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 @@
1import { useAtomValue } from "@effect/atom-react";1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import {2import * 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";
11import { useEffect, useMemo } from "react";
123
13import { derivePendingApprovals, derivePendingUserInputs } from "../lib/threadActivity";4import { useEnvironmentThread } from "./threads";
14import { appAtomRegistry } from "./atom-registry";
15import {
16 getEnvironmentClient,
17 subscribeEnvironmentConnections,
18} from "./environment-session-registry";
19import { useThreadSelection } from "./use-thread-selection";5import { useThreadSelection } from "./use-thread-selection";
206
21function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean {7export 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}
4111
42const threadDetailManager = createThreadDetailManager({12export 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
59export 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}
7315
74export function useSelectedThreadDetail() {16export 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}
8023
81 return useMemo(() => state.data, [state.data]);24export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { type MessageId } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";
6import { useCallback, useEffect, useRef, useState } from "react";
7
8import { scopedThreadKey } from "../lib/scopedEntities";
9import { appAtomRegistry } from "./atom-registry";
10import { useThreadShells } from "./entities";
11import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox";
12import {
13 resolveThreadOutboxDeliveryAction,
14 shouldRetryThreadOutboxDelivery,
15 threadOutboxRetryDelayMs,
16 type QueuedThreadMessage,
17} from "./thread-outbox-model";
18import { threadEnvironment } from "./threads";
19import { useAtomCommand } from "./use-atom-command";
20import { useThreadOutboxMessages, useThreadOutboxShellStatuses } from "./use-thread-outbox";
21import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
22
23export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(
24 Atom.keepAlive,
25 Atom.withLabel("mobile:thread-outbox:dispatching-message-id"),
26);
27
28function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {
29 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);
30}
31
32function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void {
33 const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom);
34 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current);
35}
36
37function 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
47export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
3import type { EnvironmentId } from "@t3tools/contracts";
4import { Atom } from "effect/unstable/reactivity";
5
6import { environmentShell } from "./shell";
7import { threadOutboxManager } from "./thread-outbox";
8
9const 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
22export function useThreadOutboxMessages() {
23 return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom);
24}
25
26export 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 @@
1import { useLocalSearchParams } from "expo-router";1import { useLocalSearchParams } from "expo-router";
2import { useMemo } from "react";2import { useMemo } from "react";
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";3import { EnvironmentId, ThreadId, type ScopedProjectRef } from "@t3tools/contracts";
44
5import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";5import { useProject, useThreadShell } from "../state/entities";
6import { EnvironmentScopedProjectShell } from "@t3tools/client-runtime";6import {
7import { useRemoteCatalog } from "./use-remote-catalog";7 useRemoteEnvironmentRuntime,
8import { useRemoteEnvironmentState } from "./use-remote-environment-registry";8 useSavedRemoteConnection,
9} from "./use-remote-environment-registry";
910
10function firstRouteParam(value: string | string[] | undefined): string | null {11function 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}
1718
18function 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
35function 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
52export function useThreadSelection() {19export 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 );
7547 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;
8751
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 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import { type VcsActionState, type VcsActionTarget } from "@t3tools/client-runtime/state/vcs";
3 type VcsActionState,3import { 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";
11import { useCallback, useEffect, useRef, useState } from "react";4import { useCallback, useEffect, useRef, useState } from "react";
125
13import { uuidv4 } from "../lib/uuid";
14import { appAtomRegistry } from "./atom-registry";6import { appAtomRegistry } from "./atom-registry";
15import { getEnvironmentClient } from "./environment-session-registry";7import { vcsActionManager } from "./vcs";
16
17export 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});
258
26export function useVcsActionState(target: VcsActionTarget): VcsActionState {9export 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}
3312
34// ---------------------------------------------------------------------------
35// Git action result notification
36// ---------------------------------------------------------------------------
37
38export interface GitActionResultNotification {13export 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 {
4419
45const RESULT_DISMISS_MS = 5_000;20const RESULT_DISMISS_MS = 5_000;
4621
47type ResultListener = (result: GitActionResultNotification | null) => void;22const gitActionResultAtom = Atom.make<GitActionResultNotification | null>(null).pipe(
48const resultListeners = new Set<ResultListener>();23 Atom.keepAlive,
49let currentResult: GitActionResultNotification | null = null;24 Atom.withLabel("mobile:git-action-result"),
25);
50let dismissTimer: ReturnType<typeof setTimeout> | null = null;26let dismissTimer: ReturnType<typeof setTimeout> | null = null;
5127
52function broadcast(result: GitActionResultNotification | null): void {28function 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}
5831
59export function showGitActionResult(result: GitActionResultNotification): void {32export 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}
6440
65export function dismissGitActionResult(): void {41export function dismissGitActionResult(): void {
66 if (dismissTimer) clearTimeout(dismissTimer);42 if (dismissTimer) clearTimeout(dismissTimer);
43 dismissTimer = null;
67 broadcast(null);44 broadcast(null);
68}45}
6946
...@@ -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}
8654
87// ---------------------------------------------------------------------------
88// Unified git action progress (combines running state + result notification)
89// ---------------------------------------------------------------------------
90
91export type GitActionProgressPhase = "idle" | "running" | "success" | "error";55export type GitActionProgressPhase = "idle" | "running" | "success" | "error";
9256
93export interface GitActionProgress {57export interface GitActionProgress {
apps/mobile/src/state/use-vcs-refs.ts deleted-51
...@@ -1,51 +0,0 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useEffect, useMemo } from "react";
3import {
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
13import { appAtomRegistry } from "./atom-registry";
14import {
15 getEnvironmentClient,
16 subscribeEnvironmentConnections,
17} from "./environment-session-registry";
18
19const VCS_REF_LIST_LIMIT = 100;
20const VCS_REF_STALE_TIME_MS = 5_000;
21
22export 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
36export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
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";
11import { useEffect } from "react";
12
13import { appAtomRegistry } from "./atom-registry";
14import {
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 */
31export 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 */
50export 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 @@
1import {
2 createVcsActionManager,
3 createVcsEnvironmentAtoms,
4} from "@t3tools/client-runtime/state/vcs";
5
6import { connectionAtomRuntime } from "../connection/runtime";
7
8export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime);
9export const vcsActionManager = createVcsActionManager(connectionAtomRuntime);
apps/mobile/src/state/workspace.ts created+30
...@@ -0,0 +1,30 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useMemo } from "react";
3
4import { environmentShellSummaryAtom } from "./shell";
5import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel";
6import { useEnvironments } from "./environments";
7
8export 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 @@
1import type { EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell";
2import {
3 BearerConnectionProfile,
4 BearerConnectionTarget,
5} from "@t3tools/client-runtime/connection";
6import { EnvironmentId } from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";
8import * as Option from "effect/Option";
9
10import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel";
11import type { EnvironmentPresentation } from "./environments";
12
13const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
14
15function 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
49const EMPTY_SHELL_SUMMARY: EnvironmentShellSummary = {
50 hasSnapshot: false,
51 hasSynchronizingShell: false,
52 hasCachedShell: false,
53 hasLiveShell: false,
54 firstError: null,
55 latestSnapshotUpdatedAt: null,
56};
57
58const 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
66describe("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 @@
1import { type EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell";
2import { type NetworkStatus } from "@t3tools/client-runtime/connection";
3import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
4import type { EnvironmentId, ServerConfig } from "@t3tools/contracts";
5
6import type { EnvironmentPresentation } from "./environments";
7
8export 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
18export 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
33export 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
47function 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
75export 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
107export 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);
3840
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", () => {
5052
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);
123126
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 });
135138
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";
25import { causeErrorTag } from "@t3tools/shared/observability";25import { causeErrorTag } from "@t3tools/shared/observability";
26import * as DateTime from "effect/DateTime";26import * as DateTime from "effect/DateTime";
27import * as Effect from "effect/Effect";27import * as Effect from "effect/Effect";
28import { identity } from "effect/Function";
28import * as Layer from "effect/Layer";29import * as Layer from "effect/Layer";
29import * as Cookies from "effect/unstable/http/Cookies";30import * as Cookies from "effect/unstable/http/Cookies";
30import * as HttpEffect from "effect/unstable/http/HttpEffect";31import * 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";
3334
34import * as EnvironmentAuth from "./EnvironmentAuth.ts";35import * as EnvironmentAuth from "./EnvironmentAuth.ts";
35import * as SessionStore from "./SessionStore.ts";36import * as SessionStore from "./SessionStore.ts";
37import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts";
36import { deriveAuthClientMetadata } from "./utils.ts";38import { deriveAuthClientMetadata } from "./utils.ts";
37import { verifyRequestDpopProof } from "./dpop.ts";39import { verifyRequestDpopProof } from "./dpop.ts";
3840
...@@ -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";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import * as RelayClient from "@t3tools/shared/relayClient";11import * as RelayClient from "@t3tools/shared/relayClient";
1212
13import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts";13import {
14 classifyRelayClientOutput,
15 makeCloudManagedEndpointRuntime,
16} from "./ManagedEndpointRuntime.ts";
1417
15const relayClientAvailableLayer = Layer.succeed(18const 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}
5861
59describe("CloudManagedEndpointRuntime", () => {62describe("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";
9import * as Result from "effect/Result";9import * as Result from "effect/Result";
10import * as Semaphore from "effect/Semaphore";10import * as Semaphore from "effect/Semaphore";
11import * as Scope from "effect/Scope";11import * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";13import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1314
14import * as ServerSecretStore from "../auth/ServerSecretStore.ts";15import * 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}
7071
72export 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
71function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string {79function 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 );
143151
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";
11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";12import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
13import * as CliTokenManager from "./CliTokenManager.ts";13import * as CliTokenManager from "./CliTokenManager.ts";
14import {14import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts";
15 consumeCloudReplayGuards,
16 reconcileDesiredCloudLink,
17 traceRelayBrokerHandler,
18} from "./http.ts";
19import {15import {
20 CloudManagedEndpointRuntime,16 CloudManagedEndpointRuntime,
21 type CloudManagedEndpointRuntimeShape,17 type CloudManagedEndpointRuntimeShape,
22} from "./ManagedEndpointRuntime.ts";18} from "./ManagedEndpointRuntime.ts";
19import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts";
2320
24const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>21const 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});
7774
78describe("traceRelayBrokerHandler", () => {75describe("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 );
96123
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";
48import * as Option from "effect/Option";48import * as Option from "effect/Option";
49import * as Schema from "effect/Schema";49import * as Schema from "effect/Schema";
50import * as HttpEffect from "effect/unstable/http/HttpEffect";50import * as HttpEffect from "effect/unstable/http/HttpEffect";
51import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/unstable/http";51import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
52import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";52import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
53import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";53import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
5454
...@@ -77,6 +77,7 @@ import { relayUrlConfig } from "./publicConfig.ts";...@@ -77,6 +77,7 @@ import { relayUrlConfig } from "./publicConfig.ts";
77import * as CliState from "./CliState.ts";77import * as CliState from "./CliState.ts";
78import * as CliTokenManager from "./CliTokenManager.ts";78import * as CliTokenManager from "./CliTokenManager.ts";
79import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";79import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
80import { traceRelayRequest } from "./traceRelayRequest.ts";
8081
81const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-";82const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-";
82const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-";83const 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);
113114
114export 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
127function bytesToString(bytes: Uint8Array): string {115function 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 @@
1import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import { HttpServerRequest, HttpTraceContext } from "effect/unstable/http";
5
6export const traceRelayRequest = <A, E, R>(
7 effect: Effect.Effect<A, E, R>,
8): Effect.Effect<A, E, R> => effect.pipe(withRelayClientTracing);
9
10export 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
47import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";47import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
48import { ServerSettingsService } from "../serverSettings.ts";48import { ServerSettingsService } from "../serverSettings.ts";
49import type { GitManagerServiceError } from "@t3tools/contracts";49import type { GitManagerServiceError } from "@t3tools/contracts";
50import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts";50import {
51 GitVcsDriver,
52 type GitRemoteStatusOptions,
53 type GitStatusDetails,
54} from "../vcs/GitVcsDriver.ts";
51import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts";55import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts";
52import type { ChangeRequest } from "@t3tools/contracts";56import type { ChangeRequest } from "@t3tools/contracts";
5357
...@@ -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* gitCore757 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";
3030
31import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts";31import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts";
32import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";32import { GitVcsDriver, type GitRemoteStatusOptions } from "../vcs/GitVcsDriver.ts";
33import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";33import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
3434
35export interface GitWorkflowServiceShape {35export 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";
33import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";33import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
34import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";34import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
35import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
35import {36import {
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) =>
7171
72async function waitFor(72async 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}
9898
99describe.sequential("signRelayAgentActivityPublishProof", () => {99describe.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}
102102
103export 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
103const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160;113const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160;
104const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed.";114const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed.";
105115
...@@ -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 });
446456
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 });
457474
458 const worker = yield* makeDrainableWorker(publishThread);475 const worker = yield* makeDrainableWorker(publishThread);
459476
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 );
321321
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 );
480505
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 });
23272328
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 });
23432344
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}
170170
171export interface GitRemoteStatusOptions {
172 readonly refreshUpstream?: boolean;
173}
174
171export interface GitVcsDriverShape {175export 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 );
185185
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*
14441444
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 });
14541456
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};
4545
46const 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
46const baseStatus: VcsStatusResult = {58const 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 });
354368
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 };
364379
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]);
373416
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 });
379510
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(
275275
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 });
283286
...@@ -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_INTERVAL313 ? 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 }
314319
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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveAssetUrl } from "./assetUrls";
4
5describe("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 @@
1import { useAtomValue } from "@effect/atom-react";
2import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";3import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
2import { useEffect, useMemo, useState } from "react";4import { AsyncResult } from "effect/unstable/reactivity";
5import { useMemo } from "react";
36
4import { readEnvironmentApi } from "~/environmentApi";7import { assetEnvironment } from "~/state/assets";
5import { readEnvironmentConnection } from "~/environments/runtime";8import { usePreparedConnection } from "~/state/session";
69
7const REFRESH_MARGIN_MS = 30_000;10export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
811
9interface CachedAssetUrl {12export 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({
1316 environmentId,
14const assetUrlCache = new Map<string, CachedAssetUrl>();17 input: { resource },
15const assetUrlRequests = new Map<string, Promise<CachedAssetUrl>>();18 }),
1619 );
17function 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
21export 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 }
3523 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}
5525
56export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null {26export 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);
6131 const results = useAtomValue(
62 useEffect(() => {32 assetEnvironment.createUrls({
63 let cancelled = false;33 environmentId,
64 let refreshTimer: ReturnType<typeof setTimeout> | undefined;34 resources,
6535 }),
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";
22
3import { parseScopedThreadKey } from "@t3tools/client-runtime";3import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
4import { useEffect, useMemo } from "react";4import { useEffect, useMemo } from "react";
55
6import { isElectron } from "~/env";6import { isElectron } from "~/env";
7import { useTheme } from "~/hooks/useTheme";7import { useTheme } from "~/hooks/useTheme";
8import { usePreviewStateStore } from "~/previewStateStore";8import { useActivePreviewSessions } from "~/previewStateStore";
99
10import { readPreviewAnnotationTheme } from "./annotationTheme";10import { readPreviewAnnotationTheme } from "./annotationTheme";
11import { useBrowserPointerStore } from "./browserPointerStore";11import { useBrowserPointerStore } from "./browserPointerStore";
...@@ -13,7 +13,7 @@ import { HostedBrowserWebview } from "./HostedBrowserWebview";...@@ -13,7 +13,7 @@ import { HostedBrowserWebview } from "./HostedBrowserWebview";
1313
14export function ElectronBrowserHost() {14export 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";
7import { previewBridge } from "~/components/preview/previewBridge";7import { previewBridge } from "~/components/preview/previewBridge";
8import { usePreviewBridge } from "~/components/preview/usePreviewBridge";8import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
99
10import { useBrowserRecordingStore } from "./browserRecording";10import { useActiveBrowserRecordingTabId } from "./browserRecording";
11import { useBrowserSurfaceStore } from "./browserSurfaceStore";11import { useBrowserSurfaceStore } from "./browserSurfaceStore";
12import { acquireDesktopTab } from "./desktopTabLifetime";12import { acquireDesktopTab } from "./desktopTabLifetime";
13import { usePreviewWebviewConfig } from "./previewWebviewConfigState";13import { 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;
4040
41 usePreviewBridge({ threadRef, tabId });41 usePreviewBridge({ threadRef, tabId });
4242
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";
5import { create } from "zustand";5import { useAtomValue } from "@effect/atom-react";
6import { Atom } from "effect/unstable/reactivity";
67
7import { previewBridge } from "~/components/preview/previewBridge";8import { previewBridge } from "~/components/preview/previewBridge";
9import { appAtomRegistry } from "~/rpc/atomRegistry";
8import { useBrowserSurfaceStore } from "./browserSurfaceStore";10import { useBrowserSurfaceStore } from "./browserSurfaceStore";
911
10interface ActiveRecording {12interface ActiveRecording {
...@@ -17,21 +19,14 @@ interface ActiveRecording {...@@ -17,21 +19,14 @@ interface ActiveRecording {
17 readonly startedAt: string;19 readonly startedAt: string;
18}20}
1921
20interface BrowserRecordingState {22const 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}
2726
28export const useBrowserRecordingStore = create<BrowserRecordingState>()((set) => ({27export 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}));
3530
36let active: ActiveRecording | null = null;31let active: ActiveRecording | null = null;
37let unsubscribeFrames: (() => void) | null = null;32let 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};
5853
59export async function startBrowserRecording(tabId: string): Promise<void> {54const 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
63const clearActiveRecording = (recording: ActiveRecording): void => {
64 if (active !== recording) return;
65 active = null;
66 unsubscribeFrames?.();
67 unsubscribeFrames = null;
68 appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null);
69};
70
71export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
33
4const readEnvironmentConnection = vi.fn();4const readPreparedConnection = vi.fn();
55
6vi.mock("~/environments/runtime", () => ({ readEnvironmentConnection }));6vi.mock("~/state/session", () => ({ readPreparedConnection }));
77
8describe("browser target resolver", () => {8describe("browser target resolver", () => {
9 beforeEach(() => readEnvironmentConnection.mockReset());9 beforeEach(() => readPreparedConnection.mockReset());
1010
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 });
2927
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 });
4238
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 });
6256
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";
6import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";6import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
77
8import { readEnvironmentConnection } from "~/environments/runtime";8import { readPreparedConnection } from "~/state/session";
99
10const isPrivateNetworkHost = (host: string): boolean => {10const 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 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";1import type {
2 AssetCreateUrlResult,
3 AssetResource,
4 EnvironmentId,
5 PreviewOpenInput,
6 PreviewSessionSnapshot,
7 ScopedThreadRef,
8} from "@t3tools/contracts";
9import {
10 type AtomCommandResult,
11 mapAtomCommandResult,
12} from "@t3tools/client-runtime/state/runtime";
13import * as Cause from "effect/Cause";
14import * as Data from "effect/Data";
15import { AsyncResult } from "effect/unstable/reactivity";
216
3import { readEnvironmentApi } from "~/environmentApi";
4import { resolveAssetUrl } from "~/assets/assetUrls";17import { resolveAssetUrl } from "~/assets/assetUrls";
5import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore";18import {
19 applyPreviewServerSnapshot,
20 isPreviewSupportedInRuntime,
21 rememberPreviewUrl,
22} from "~/previewStateStore";
6import { useRightPanelStore } from "~/rightPanelStore";23import { useRightPanelStore } from "~/rightPanelStore";
724
8export const isBrowserPreviewFile = (path: string): boolean =>25export const isBrowserPreviewFile = (path: string): boolean =>
9 /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");26 /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
1027
11export async function openUrlInPreview(threadRef: ScopedThreadRef, url: string): Promise<void> {28export 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
34export type OpenPreviewMutation<E = unknown> = (input: {
35 readonly environmentId: EnvironmentId;
36 readonly input: PreviewOpenInput;
37}) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;
1638
17 const snapshot = await api.preview.open({ threadId: threadRef.threadId, url });39export 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}
2254
23export async function openFileInPreview(55export 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 @@
1import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";1import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
33
4const testEnvironmentId = EnvironmentId.make("environment-1");
5
6const 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
21function createLocalStorageStub(): Storage {4function 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});
5639
57describe("clientPersistenceStorage", () => {40describe("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
6750 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 @@
1import {1import { 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";
8import * as Schema from "effect/Schema";
92
10import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";3import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";
114
12export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";5export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
13export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1";
14
15const 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});
33type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type;
34
35const BrowserSavedEnvironmentRegistryDocumentSchema = Schema.Struct({
36 version: Schema.optionalKey(Schema.Number),
37 records: Schema.optionalKey(Schema.Array(BrowserSavedEnvironmentRecordSchema)),
38});
39type BrowserSavedEnvironmentRegistryDocument =
40 typeof BrowserSavedEnvironmentRegistryDocumentSchema.Type;
416
42function hasWindow(): boolean {7function hasWindow(): boolean {
43 return typeof window !== "undefined";8 return typeof window !== "undefined";
44}9}
4510
46function 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
64export function readBrowserClientSettings(): ClientSettings | null {11export 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 {
8027
81 setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema);28 setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema);
82}29}
83
84function 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
100function 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
114function readBrowserSavedEnvironmentRecordsWithSecrets(): ReadonlyArray<BrowserSavedEnvironmentRecord> {
115 return readBrowserSavedEnvironmentRegistryDocument().records ?? [];
116}
117
118function writeBrowserSavedEnvironmentRecords(
119 records: ReadonlyArray<BrowserSavedEnvironmentRecord>,
120): void {
121 writeBrowserSavedEnvironmentRegistryDocument({
122 version: 1,
123 records,
124 });
125}
126
127export function readBrowserSavedEnvironmentRegistry(): ReadonlyArray<PersistedSavedEnvironmentRecord> {
128 return readBrowserSavedEnvironmentRecordsWithSecrets().map((record) =>
129 toPersistedSavedEnvironmentRecord(record),
130 );
131}
132
133export 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
161export function readBrowserSavedEnvironmentSecret(
162 environmentId: EnvironmentIdValue,
163): string | null {
164 return (
165 readBrowserSavedEnvironmentRecordsWithSecrets().find(
166 (record) => record.environmentId === environmentId,
167 )?.bearerToken ?? null
168 );
169}
170
171export 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
206export 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 @@
1import { verifyDpopProof } from "@t3tools/shared/dpop";1import { verifyDpopProof } from "@t3tools/shared/dpop";
2import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import { describe, expect, it, vi } from "vite-plus/test";4import { decodeJwt } from "jose";
5import { vi } from "vite-plus/test";
46
5import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop";7import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop";
68
7describe("browser DPoP proofs", () => {9describe("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");
2022
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 @@
1import { EnvironmentId } from "@t3tools/contracts";1import {
2 EnvironmentId,
3 type RelayClientInstallProgressEvent,
4 WS_METHODS,
5} from "@t3tools/contracts";
2import { RelayWebClientId } from "@t3tools/contracts/relay";6import { RelayWebClientId } from "@t3tools/contracts/relay";
3import { afterEach, beforeEach, vi } from "vite-plus/test";
4import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Stream from "effect/Stream";
12import * as SubscriptionRef from "effect/SubscriptionRef";
7import { HttpClient } from "effect/unstable/http";13import { HttpClient } from "effect/unstable/http";
14import { afterEach, beforeEach, vi } from "vite-plus/test";
15import {
16 AVAILABLE_CONNECTION_STATE,
17 type EnvironmentRegistryService,
18 EnvironmentSupervisor,
19 type EnvironmentSupervisorService,
20 type PreparedConnection,
21 PrimaryConnectionTarget,
22} from "@t3tools/client-runtime/connection";
23import { type RpcSession } from "@t3tools/client-runtime/rpc";
24import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
8import {25import {
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";30import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
1431
15import type { SavedEnvironmentRecord } from "../environments/runtime";
16import {32import {
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";
25import {
26 readPrimaryEnvironmentDescriptor,
27 readPrimaryEnvironmentTarget,
28 resolvePrimaryEnvironmentHttpUrl,
29} from "../environments/primary";
3041
31const getSavedEnvironmentSecretMock = vi.fn();42const TARGET: CloudLinkTarget = {
32const 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
49const 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}));
37const getRelayClientStatusMock = vi.fn();
38const installRelayClientMock = vi.fn();
39const environmentConnectionMock = {
40 client: {
41 cloud: {
42 getRelayClientStatus: getRelayClientStatusMock,
43 installRelayClient: installRelayClientMock,
44 },
45 },
46};
4754
48const createProofMock = vi.fn(55vi.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,
52const testDpopSignerLayer = Layer.succeed(59}));
60
61const createProof = vi.fn(() => Effect.succeed("dpop-proof"));
62const 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);
5969
60function cloudClientLayer() {70function 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}
7080
71const withCloudServices = <A, E>(81function 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>;
7484}) {
75vi.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]: () =>
8292 Stream.fromIterable(options?.installEvents ?? []),
83vi.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,
8898 probe: Effect.void,
89vi.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,
94104 httpBaseUrl: TARGET.httpBaseUrl,
95vi.mock("../environments/runtime", () => ({105 wsBaseUrl: TARGET.wsBaseUrl,
96 getPrimaryEnvironmentConnection: () => environmentConnectionMock,106 });
97 readEnvironmentConnection: () => environmentConnectionMock,107 const supervisor = EnvironmentSupervisor.of({
98}));108 target,
99109 state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
100const 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>) =>
108118 Effect.provideService(effect, EnvironmentSupervisor, supervisor),
109function 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}
112126
113function validChallenge() {127function 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}
119130
120function availableRelayClient() {131function 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}
128137
129function requestBodyText(body: BodyInit | null | undefined): string {138function 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}
132141
133describe("web cloud link environment client", () => {142beforeEach(() => {
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 });
140147
141 beforeEach(() => {148afterEach(() => {
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 });
156153
157 it("normalizes configured relay base URLs before building relay requests", () => {154describe("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 });
163167
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);
240187
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 );
387196
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);
616209
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);
658211
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 );
664221 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 );
677226
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 = vi229 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);
816258
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 );
830280
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 = vi283 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);
854284
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 );
868299
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 = vi302 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);
895309
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 );
902316
903 // @effect-diagnostics-next-line preferSchemaOverJson:off317 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 @@
1import * as Data from "effect/Data";1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
4import { HttpClient, HttpTraceContext, type Headers } from "effect/unstable/http";5import * as Stream from "effect/Stream";
6import { HttpClient } from "effect/unstable/http";
5import {7import {
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";
15import {18import {
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";
24import {25import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
25 exchangeRemoteDpopAccessToken,26import { request, runStream } from "@t3tools/client-runtime/rpc";
26 fetchRemoteEnvironmentDescriptor,27import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
27 makeEnvironmentHttpApiClient,28import { ManagedRelayClient, type ManagedRelayClientError } from "@t3tools/client-runtime/relay";
28 ManagedRelayClient,
29 ManagedRelayDpopSigner,
30 type WsRpcClient,
31} from "@t3tools/client-runtime";
32import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3329
34import { ensureLocalApi } from "../localApi";
35import {
36 getPrimaryEnvironmentConnection,
37 readEnvironmentConnection,
38 type SavedEnvironmentRecord,
39} from "../environments/runtime";
40import {30import {
41 readPrimaryEnvironmentDescriptor,31 readPrimaryEnvironmentDescriptor,
42 readPrimaryEnvironmentTarget,32 readPrimaryEnvironmentTarget,
43 resolvePrimaryEnvironmentHttpUrl,
44} from "../environments/primary";33} from "../environments/primary";
45import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit";34import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit";
46import { resolveCloudPublicConfig } from "./publicConfig";35import { resolveCloudPublicConfig } from "./publicConfig";
...@@ -65,6 +54,7 @@ function relayUrl(): string | null {...@@ -65,6 +54,7 @@ function relayUrl(): string | null {
65export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{54export 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}> {}
6959
70const relayClientRpcError = (message: string) => (cause: unknown) =>60const 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 });
7565
76function ensureRelayClientAvailable(66function 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 }
10090
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}
115119
116const isRelayProtectedError = Schema.is(RelayProtectedError);
117const isEnvironmentCloudApiError = Schema.is(120const 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}
162165
163function decodedRelayClientError(message: string) {166function 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}
173177
174function 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
184function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null {178function 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 {
239233
240export type CloudLinkState = EnvironmentCloudLinkStateResult;234export type CloudLinkState = EnvironmentCloudLinkStateResult;
241235
242export 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
252export function collectCloudLinkTargets(input: {236export 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}
338322
339export function connectManagedCloudEnvironment(input: {323export 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
453export 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.connect328 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}
471336
472export function updatePrimaryCloudPreferences(input: {337export 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.connect343 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}
488354
489export function unlinkPrimaryEnvironmentFromCloud(input: {355export 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.connect361 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* relayClient371 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}
525386
526export 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
632export function linkPrimaryEnvironmentToCloud(input: {387export 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);
651405
652 const challenge = yield* relayClient406 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 @@
1import {
2 createAtomCommandScheduler,
3 createRuntimeCommand,
4} from "@t3tools/client-runtime/state/runtime";
5
6import { connectionAtomRuntime } from "../connection/runtime";
7import {
8 linkPrimaryEnvironmentToCloud,
9 type CloudLinkTarget,
10 unlinkPrimaryEnvironmentFromCloud,
11} from "./linkEnvironment";
12
13const cloudLinkScheduler = createAtomCommandScheduler();
14const cloudLinkConcurrency = {
15 mode: "serial" as const,
16 key: (input: { readonly target: CloudLinkTarget }) => input.target.environmentId,
17};
18
19export 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
27export 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 @@
1import { managedRelaySessionAtom, setManagedRelaySession } from "@t3tools/client-runtime/relay";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { appAtomRegistry } from "../rpc/atomRegistry";
5import {
6 activateManagedRelayAuthentication,
7 deactivateManagedRelayAuthentication,
8 readManagedRelayClerkToken,
9} from "./managedAuth";
10
11vi.mock("@clerk/react", () => ({
12 useAuth: vi.fn(),
13}));
14
15vi.mock("../lib/runtime", () => ({
16 runtime: {
17 runPromiseExit: vi.fn(),
18 },
19}));
20
21vi.mock("../connection/catalog", () => ({
22 environmentCatalog: {
23 removeRelayEnvironments: {},
24 },
25}));
26
27afterEach(() => {
28 deactivateManagedRelayAuthentication();
29});
30
31describe("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 @@
1import { useAuth } from "@clerk/react";1import { useAuth } from "@clerk/react";
2import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime";2import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay";
3import { useEffect, type ReactNode } from "react";3import {
4 reportAtomCommandResult,
5 settleAsyncResult,
6 settlePromise,
7} from "@t3tools/client-runtime/state/runtime";
8import * as Effect from "effect/Effect";
9import { useEffect, useRef, type ReactNode } from "react";
410
11import { environmentCatalog } from "../connection/catalog";
12import { runtime } from "../lib/runtime";
5import { appAtomRegistry } from "../rpc/atomRegistry";13import { appAtomRegistry } from "../rpc/atomRegistry";
14import { useAtomCommand } from "../state/use-atom-command";
6import { resolveRelayClerkTokenOptions } from "./publicConfig";15import { resolveRelayClerkTokenOptions } from "./publicConfig";
716
8let relayTokenProvider: (() => Promise<string | null>) | null = null;17let 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}
1322
23export function deactivateManagedRelayAuthentication(): void {
24 relayTokenProvider = null;
25 setManagedRelaySession(appAtomRegistry, null);
26}
27
28export 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
14export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) {39export 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);
1649
17 useEffect(() => {50 useEffect(() => {
18 relayTokenProvider = isSignedIn ? () => getToken(resolveRelayClerkTokenOptions()) : null;51 if (!isLoaded) {
19 setManagedRelaySession(52 return;
20 appAtomRegistry,53 }
21 isSignedIn && userId54
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(), []);
33112
34 return children;113 return children;
35}114}
apps/web/src/cloud/managedRelayLayer.ts+19-15
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import {1import {
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";
6import { RelayWebClientId } from "@t3tools/contracts/relay";6import { RelayWebClientId } from "@t3tools/contracts/relay";
7import * as Crypto from "effect/Crypto";7import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";8import * 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";
1919
20export const webRelayDpopSignerLayer = Layer.effect(20export 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);
5862
59export const webManagedRelayClientLayer = (relayUrl: string) =>63export 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";
8import type {8import 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";
13import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";14import * as Layer from "effect/Layer";
15import { AsyncResult, Atom } from "effect/unstable/reactivity";15import { AsyncResult, Atom } from "effect/unstable/reactivity";
16import { useCallback } from "react";16import { useCallback, useEffect } from "react";
1717
18import { webRuntime } from "../lib/runtime";18import { runtime } from "../lib/runtime";
19import { appAtomRegistry } from "../rpc/atomRegistry";19import { appAtomRegistry } from "../rpc/atomRegistry";
2020
21const managedRelayAtomRuntime = Atom.runtime(21const 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);
2927
...@@ -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]);
5259
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]);
7086
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 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentCloudLinkStateResult, EnvironmentId } from "@t3tools/contracts";2import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";5import * 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";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import { AsyncResult, Atom } from "effect/unstable/reactivity";8import { AsyncResult, Atom } from "effect/unstable/reactivity";
9import { HttpClient } from "effect/unstable/http";9import { HttpClient } from "effect/unstable/http";
10import { useCallback } from "react";10import { useCallback, useMemo } from "react";
1111
12import { usePrimaryEnvironmentId } from "../environments/primary";12import { usePrimaryEnvironment } from "../state/environments";
13import { webRuntime } from "../lib/runtime";13import { runtime } from "../lib/runtime";
14import { appAtomRegistry } from "../rpc/atomRegistry";14import { appAtomRegistry } from "../rpc/atomRegistry";
15import { readPrimaryCloudLinkState } from "./linkEnvironment";15import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment";
1616
17const primaryCloudLinkAtomRuntime = Atom.runtime(17const 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);
2525
26const primaryCloudLinkStateAtom = Atom.family((environmentId: EnvironmentId) =>26const primaryCloudLinkStateAtom = Atom.family((key: string) => {
27 primaryCloudLinkAtomRuntime27 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});
3536
36const EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM = Atom.make(37const 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"));
3940
40export function refreshPrimaryCloudLinkState(environmentId: EnvironmentId | null): void {41function targetKey(target: CloudLinkTarget): string {
41 if (environmentId) {42 return JSON.stringify(target);
42 appAtomRegistry.refresh(primaryCloudLinkStateAtom(environmentId));43}
44
45export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void {
46 if (target) {
47 appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target)));
43 }48 }
44}49}
4550
46export function usePrimaryCloudLinkState() {51export function usePrimaryCloudLinkState() {
47 const environmentId = usePrimaryEnvironmentId();52 const primary = usePrimaryEnvironment();
48 const atom = environmentId53 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 @@
1import { createContext, use, type ReactNode } from "react";
2
3const OpenAddProjectCommandPaletteContext = createContext<(() => void) | null>(null);
4
5export 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
16export 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. */
25export 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 @@
1import { create } from "zustand";
2
3interface CommandPaletteOpenIntent {
4 kind: "add-project";
5 requestId: number;
6}
7
8interface CommandPaletteStore {
9 open: boolean;
10 openIntent: CommandPaletteOpenIntent | null;
11 setOpen: (open: boolean) => void;
12 toggleOpen: () => void;
13 openAddProject: () => void;
14 clearOpenIntent: () => void;
15}
16
17export 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";
33
4import ThreadSidebar from "./Sidebar";4import ThreadSidebar from "./Sidebar";
5import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";5import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
6import {
7 clearShortcutModifierState,
8 syncShortcutModifierStateFromKeyboardEvent,
9} from "../shortcutModifierState";
106
11const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";7const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
12const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;8const 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;
14export function AppSidebarLayout({ children }: { children: ReactNode }) {10export function AppSidebarLayout({ children }: { children: ReactNode }) {
15 const navigate = useNavigate();11 const navigate = useNavigate();
1612
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 @@
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import {3import {
4 ChevronDownIcon,4 ChevronDownIcon,
...@@ -11,9 +11,8 @@ import {...@@ -11,9 +11,8 @@ import {
11import { memo, useMemo } from "react";11import { memo, useMemo } from "react";
1212
13import { useComposerDraftStore, type DraftId } from "../composerDraftStore";13import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
14import { useProject, useThread } from "../state/entities";
14import { useIsMobile } from "../hooks/useMediaQuery";15import { useIsMobile } from "../hooks/useMediaQuery";
15import { useStore } from "../store";
16import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
17import {16import {
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 : draftThread215 : 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);
235229
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 @@
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";6import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
3import { LegendList, type LegendListRef } from "@legendapp/list/react";7import { LegendList, type LegendListRef } from "@legendapp/list/react";
4import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react";8import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react";
...@@ -15,15 +19,15 @@ import {...@@ -15,15 +19,15 @@ import {
15} from "react";19} from "react";
1620
17import { useComposerDraftStore, type DraftId } from "../composerDraftStore";21import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
18import { readEnvironmentApi } from "../environmentApi";22import { usePaginatedBranches } from "../state/queries";
19import { useVcsStatus } from "../lib/vcsStatusState";23import { useProject, useThread } from "../state/entities";
20import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState";24import { useEnvironmentQuery } from "../state/query";
21import { newCommandId } from "../lib/utils";25import { threadEnvironment } from "../state/threads";
26import { useAtomCommand } from "../state/use-atom-command";
27import { vcsEnvironment } from "../state/vcs";
22import { cn } from "../lib/utils";28import { cn } from "../lib/utils";
23import { parsePullRequestReference } from "../pullRequestReference";29import { parsePullRequestReference } from "../pullRequestReference";
24import { getSourceControlPresentation } from "../sourceControlPresentation";30import { getSourceControlPresentation } from "../sourceControlPresentation";
25import { useStore } from "../store";
26import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
27import {31import {
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}
6064
61const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
62
63function toBranchActionErrorMessage(error: unknown): string {65function 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 : draftThread123 : 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);
120127
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 ? activeThreadBranchOverride131 ? 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.orchestration153 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 );
196199
...@@ -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);
203206
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 vcsRefManager309 branchRefState.refresh();
300 .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })310 branchStatusQuery.refresh();
301 .catch(() => undefined);
302 });311 });
303 };312 };
304313
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;
308316
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.isRemote355 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({
361374
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;
366378
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 vcsRefManager433 branchRefState.refresh();
417 .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })
418 .catch(() => undefined);
419 },434 },
420 [branchRefTarget],435 [branchRefState.refresh],
421 );436 );
422437
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 }
430445
431 setIsFetchingNextPage(true);446 branchRefState.loadNext();
432 void vcsRefManager447 }, [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 }, []);
467478
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 @@
1import "../index.css";
2
3import { page } from "vite-plus/test/browser";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5import { render } from "vitest-browser-react";
6
7const {
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
28vi.mock("../editorPreferences", () => ({
29 openInPreferredEditor: openInPreferredEditorMock,
30}));
31
32vi.mock("../localApi", () => ({
33 ensureLocalApi: vi.fn(() => {
34 throw new Error("ensureLocalApi not implemented in browser test");
35 }),
36 readLocalApi: readLocalApiMock,
37}));
38
39vi.mock("../previewStateStore", async (importOriginal) => ({
40 ...(await importOriginal<typeof import("../previewStateStore")>()),
41 isPreviewSupportedInRuntime: () => true,
42}));
43
44vi.mock("../browser/openFileInPreview", async (importOriginal) => ({
45 ...(await importOriginal<typeof import("../browser/openFileInPreview")>()),
46 openFileInPreview: openFileInPreviewMock,
47 openUrlInPreview: openUrlInPreviewMock,
48}));
49
50import ChatMarkdown from "./ChatMarkdown";
51import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard";
52import { EnvironmentId, ThreadId } from "@t3tools/contracts";
53import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
54
55const threadRef = {
56 environmentId: EnvironmentId.make("environment-test"),
57 threadId: ThreadId.make("thread-test"),
58};
59
60describe("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 @@
1import { useAtomValue } from "@effect/atom-react";
1import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs";2import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs";
2import {3import {
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";
11import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts";12import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts";
13import {
14 isAtomCommandInterrupted,
15 squashAtomCommandFailure,
16 type AtomCommandResult,
17} from "@t3tools/client-runtime/state/runtime";
18import * as Cause from "effect/Cause";
19import { AsyncResult } from "effect/unstable/reactivity";
12import React, {20import 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
41import { ScrollArea } from "./ui/scroll-area";49import { ScrollArea } from "./ui/scroll-area";
42import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";50import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
43import { stackedThreadToast, toastManager } from "./ui/toast";51import { stackedThreadToast, toastManager } from "./ui/toast";
44import { openInPreferredEditor } from "../editorPreferences";52import { useOpenInPreferredEditor } from "../editorPreferences";
45import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";53import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
46import { fnv1a32 } from "../lib/diffRendering";54import { fnv1a32 } from "../lib/diffRendering";
47import { LRUCache } from "../lib/lruCache";55import { LRUCache } from "../lib/lruCache";
...@@ -59,11 +67,19 @@ import {...@@ -59,11 +67,19 @@ import {
59import { readLocalApi } from "../localApi";67import { readLocalApi } from "../localApi";
60import { cn } from "../lib/utils";68import { cn } from "../lib/utils";
61import { useRightPanelStore } from "../rightPanelStore";69import { useRightPanelStore } from "../rightPanelStore";
70import { useActiveEnvironmentId } from "../state/entities";
71import { serverEnvironment } from "../state/server";
72import { assetEnvironment } from "../state/assets";
73import { usePreparedConnection } from "../state/session";
74import { previewEnvironment } from "../state/preview";
75import { useAtomCommand } from "../state/use-atom-command";
76import { useAtomQueryRunner } from "../state/use-atom-query-runner";
62import { isPreviewSupportedInRuntime } from "../previewStateStore";77import { isPreviewSupportedInRuntime } from "../previewStateStore";
63import {78import {
64 isBrowserPreviewFile,79 isBrowserPreviewFile,
65 openFileInPreview,80 openFileInPreview,
66 openUrlInPreview,81 openUrlInPreview,
82 BrowserPreviewUnavailableError,
67} from "../browser/openFileInPreview";83} from "../browser/openFileInPreview";
6884
69class CodeHighlightErrorBoundary extends React.Component<85class 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}
681699
...@@ -942,54 +960,6 @@ function MarkdownExternalLinkContent({...@@ -942,54 +960,6 @@ function MarkdownExternalLinkContent({
942 );960 );
943}961}
944962
945function 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
993const MarkdownFileLink = memo(function MarkdownFileLink({963const 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]);
1026994
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]);
10341002
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]);
10471022
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;
10871062
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 ...(canOpenInBrowser1066 ...(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 );
11281093
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.className1146 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 <MarkdownExternalLink1301 <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 children1341 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 );
13931446
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.
2import "../index.css";
3
4import {
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";
25import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
26import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model";
27import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
28import * as Option from "effect/Option";
29import * as Schema from "effect/Schema";
30import { HttpResponse, http, ws } from "msw";
31import { setupWorker } from "msw/browser";
32import { page } from "vite-plus/test/browser";
33import {
34 afterAll,
35 afterEach,
36 beforeAll,
37 beforeEach,
38 describe,
39 expect,
40 it,
41 vi,
42} from "vite-plus/test";
43import { render } from "vitest-browser-react";
44
45import { useCommandPaletteStore } from "../commandPaletteStore";
46import { useComposerDraftStore, DraftId } from "../composerDraftStore";
47import {
48 __resetEnvironmentApiOverridesForTests,
49 __setEnvironmentApiOverrideForTests,
50} from "../environmentApi";
51import {
52 resetSavedEnvironmentRegistryStoreForTests,
53 resetSavedEnvironmentRuntimeStoreForTests,
54 useSavedEnvironmentRegistryStore,
55 useSavedEnvironmentRuntimeStore,
56} from "../environments/runtime/catalog";
57import {
58 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
59 removeInlineTerminalContextPlaceholder,
60 type TerminalContextDraft,
61} from "../lib/terminalContext";
62import { isMacPlatform } from "../lib/utils";
63import { __resetLocalApiForTests } from "../localApi";
64import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
65import { getServerConfig } from "../rpc/serverState";
66import { getRouter } from "../router";
67import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
68import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
69import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
70import { terminalSessionManager } from "../terminalSessionState";
71import { useTerminalUiStateStore } from "../terminalUiStateStore";
72import { useUiStateStore } from "../uiStateStore";
73import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers";
74import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
75
76import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
77
78vi.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
112const THREAD_ID = "thread-browser-test" as ThreadId;
113const THREAD_TITLE = "Browser test thread";
114const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId;
115const PROJECT_ID = "project-1" as ProjectId;
116const SECOND_PROJECT_ID = "project-2" as ProjectId;
117const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
118const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote");
119const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);
120const THREAD_KEY = scopedThreadKey(THREAD_REF);
121const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
122const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`;
123const 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);
135const NOW_ISO = "2026-03-04T12:00:00.000Z";
136const BASE_TIME_MS = Date.parse(NOW_ISO);
137const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>";
138const ADD_PROJECT_SUBMENU_PLACEHOLDER = "Enter path (e.g. ~/projects/my-app)";
139
140interface TestFixture {
141 snapshot: OrchestrationReadModel;
142 serverConfig: ServerConfig;
143 welcome: ServerLifecycleWelcomePayload;
144 terminalMetadataEvents: ReadonlyArray<TerminalMetadataStreamEvent>;
145}
146
147let fixture: TestFixture;
148const rpcHarness = new BrowserWsRpcHarness();
149const wsRequests = rpcHarness.requests;
150let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null;
151const wsLink = ws.link(/ws(s)?:\/\/.*/);
152const encodeServerConfig = Schema.encodeSync(ServerConfigSchema);
153
154interface ViewportSpec {
155 name: string;
156 width: number;
157 height: number;
158 textTolerancePx: number;
159 attachmentTolerancePx: number;
160}
161
162const DEFAULT_VIEWPORT: ViewportSpec = {
163 name: "desktop",
164 width: 960,
165 height: 1_100,
166 textTolerancePx: 44,
167 attachmentTolerancePx: 56,
168};
169const WIDE_FOOTER_VIEWPORT: ViewportSpec = {
170 name: "wide-footer",
171 width: 1_400,
172 height: 1_100,
173 textTolerancePx: 44,
174 attachmentTolerancePx: 56,
175};
176const COMPACT_FOOTER_VIEWPORT: ViewportSpec = {
177 name: "compact-footer",
178 width: 430,
179 height: 932,
180 textTolerancePx: 56,
181 attachmentTolerancePx: 56,
182};
183
184interface 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
192function isoAt(offsetSeconds: number): string {
193 return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString();
194}
195
196function 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
244function 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
314function 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
338function 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
350function 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
369function 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
463function 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
484function 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
528function 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
551function 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
569function 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
589function 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
611async 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
628function threadRefFor(threadId: ThreadId) {
629 return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);
630}
631
632function threadKeyFor(threadId: ThreadId): string {
633 return scopedThreadKey(threadRefFor(threadId));
634}
635
636function composerDraftFor(target: string) {
637 const { draftsByThreadKey } = useComposerDraftStore.getState();
638 return draftsByThreadKey[target] ?? draftsByThreadKey[threadKeyFor(target as ThreadId)];
639}
640
641function 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
650function 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
658function serverThreadPath(threadId: ThreadId): string {
659 return `/${LOCAL_ENVIRONMENT_ID}/${threadId}`;
660}
661
662async 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
672async 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
679async 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
692async 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
705function 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
716function 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
728function 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
740function 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
762function 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
819function 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
913function 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
978function 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
1034function 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
1158const 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
1177async function nextFrame(): Promise<void> {
1178 await new Promise<void>((resolve) => {
1179 window.requestAnimationFrame(() => resolve());
1180 });
1181}
1182
1183async function waitForLayout(): Promise<void> {
1184 await nextFrame();
1185 await nextFrame();
1186 await nextFrame();
1187}
1188
1189async function setViewport(viewport: ViewportSpec): Promise<void> {
1190 await page.viewport(viewport.width, viewport.height);
1191 await waitForLayout();
1192}
1193
1194async 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
1209async 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
1230async 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
1246async function waitForComposerEditor(): Promise<HTMLElement> {
1247 return waitForElement(
1248 () => document.querySelector<HTMLElement>('[contenteditable="true"]'),
1249 "Unable to find composer editor.",
1250 );
1251}
1252
1253async 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
1309async 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
1325async 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
1336async 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
1416async 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
1430async 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}
1436async 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
1443function findComposerProviderModelPicker(): HTMLButtonElement | null {
1444 return document.querySelector<HTMLButtonElement>('[data-chat-provider-model-picker="true"]');
1445}
1446
1447function 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
1453async function waitForButtonByText(text: string): Promise<HTMLButtonElement> {
1454 return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`);
1455}
1456
1457function 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
1465async function waitForButtonContainingText(text: string): Promise<HTMLElement> {
1466 return waitForElement(
1467 () => findButtonContainingText(text),
1468 `Unable to find button containing "${text}".`,
1469 );
1470}
1471
1472async 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
1482async 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
1511async 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
1523async 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
1535function 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
1549function 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
1561function 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
1573async 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
1591async 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
1601async 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
1611async 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
1618async 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
1625function 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
1641async 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
1656async 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
1721describe("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 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";1import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts";
2import {2import { describe, expect, it } from "vite-plus/test";
3 EnvironmentId,
4 ProjectId,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 ThreadId,
8 TurnId,
9} from "@t3tools/contracts";
10import { afterEach, describe, expect, it, vi } from "vite-plus/test";
11import { type EnvironmentState, useStore } from "../store";
12import { type Thread } from "../types";
133
4import type { Thread } from "../types";
14import {5import {
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";
2818
29const localEnvironmentId = EnvironmentId.make("environment-local");19const environmentId = EnvironmentId.make("environment-local");
20const projectId = ProjectId.make("project-1");
21const threadId = ThreadId.make("thread-1");
22const now = "2026-03-29T00:00:00.000Z";
23
24function 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
52const 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
61const 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};
3071
31describe("deriveComposerSendState", () => {72describe("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});
103144
104describe("buildExpiredTerminalContextToastCopy", () => {145describe("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});
186224
187describe("resolveSendEnvMode", () => {225describe("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});
197231
198describe("reconcileMountedTerminalThreadIds", () => {232describe("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 });
261244
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});
278260
...@@ -321,319 +303,38 @@ describe("reconcileRetainedMountedThreadIds", () => {...@@ -321,319 +303,38 @@ describe("reconcileRetainedMountedThreadIds", () => {
321});303});
322304
323describe("shouldWriteThreadErrorToCurrentServerThread", () => {305describe("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);
327308
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});
352325
353const 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
390function 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
496afterEach(() => {
497 vi.useRealTimers();
498 vi.restoreAllMocks();
499 setStoreThreads([]);
500});
501
502describe("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
588describe("hasServerAcknowledgedLocalDispatch", () => {326describe("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 });
630331
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 });
643344
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 });
667356
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 });
689369
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 });
756382
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 });
825414
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";
12import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";12import { type ChatMessage, type SessionPhase, type Thread } from "../types";
13import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";13import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
14import * as Schema from "effect/Schema";14import * as Schema from "effect/Schema";
15import { selectThreadByRef, useStore } from "../store";15import { appAtomRegistry } from "../rpc/atomRegistry";
16import { environmentThreadDetails } from "../state/threads";
16import {17import {
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();
337338
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 };
356357
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}
385386
...@@ -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.activeTurnId439 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: {
444445
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";
24import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";24import {
25 connectionStatusText,
26 type EnvironmentConnectionPresentation,
27} from "@t3tools/client-runtime/connection";
28import {
29 parseScopedThreadKey,
30 scopedThreadKey,
31 scopeProjectRef,
32 scopeThreadRef,
33} from "@t3tools/client-runtime/environment";
25import {34import {
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
31import { truncate } from "@t3tools/shared/String";40import { truncate } from "@t3tools/shared/String";
32import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";41import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
33import { Debouncer } from "@tanstack/react-pacer";42import { Debouncer } from "@tanstack/react-pacer";
43import { useAtomValue } from "@effect/atom-react";
34import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";44import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
35import { useNavigate, useSearch } from "@tanstack/react-router";45import { useNavigate, useSearch } from "@tanstack/react-router";
36import { useShallow } from "zustand/react/shallow";46import { useShallow } from "zustand/react/shallow";
37import { useVcsStatus } from "~/lib/vcsStatusState";47import {
38import { usePrimaryEnvironmentId } from "../environments/primary/context";48 isAtomCommandInterrupted,
39import { readEnvironmentApi } from "../environmentApi";49 mapAtomCommandResult,
40import { resolveAssetUrl } from "../assets/assetUrls";50 settlePromise,
51 squashAtomCommandFailure,
52 type AtomCommandResult,
53} from "@t3tools/client-runtime/state/runtime";
54import * as Cause from "effect/Cause";
55import { AsyncResult } from "effect/unstable/reactivity";
41import { isElectron } from "../env";56import { isElectron } from "../env";
42import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
43import { readLocalApi } from "../localApi";57import { readLocalApi } from "../localApi";
44import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";58import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
45import {59import {
...@@ -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";
70import { selectEnvironmentState, selectProjectsAcrossEnvironments, useStore } from "../store";
71import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
72import { useUiStateStore } from "../uiStateStore";84import { useUiStateStore } from "../uiStateStore";
73import {85import {
74 buildPlanImplementationThreadTitle,86 buildPlanImplementationThreadTitle,
...@@ -87,7 +99,7 @@ import {...@@ -87,7 +99,7 @@ import {
87} from "../types";99} from "../types";
88import { useTheme } from "../hooks/useTheme";100import { useTheme } from "../hooks/useTheme";
89import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";101import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
90import { useCommandPaletteStore } from "../commandPaletteStore";102import { isCommandPaletteOpen } from "../commandPaletteContext";
91import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";103import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
92import { useMediaQuery } from "../hooks/useMediaQuery";104import { useMediaQuery } from "../hooks/useMediaQuery";
93import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";105import { 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";
101import {113import {
114 applyPreviewServerSnapshot,
102 isPreviewSupportedInRuntime,115 isPreviewSupportedInRuntime,
103 selectThreadPreviewState,116 removePreviewSession,
104 usePreviewStateStore,117 setActivePreviewTab,
118 useThreadPreviewState,
105} from "../previewStateStore";119} from "../previewStateStore";
106import { subscribePreviewAction } from "./preview/previewActionBus";120import { subscribePreviewAction } from "./preview/previewActionBus";
107import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";121import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
108// Lazy: keeps the entire preview component graph (webview host, favicon122import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
109// helper, Chromium error icon) out of the web bundle until first open.123import { RightPanelTabs } from "./RightPanelTabs";
110const PreviewPanel = lazy(() =>124import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
111 import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })),
112);
113const DiffPanel = lazy(() => import("./DiffPanel"));
114const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel"));
115const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet<string> = new Set();
116import { BranchToolbar } from "./BranchToolbar";125import { BranchToolbar } from "./BranchToolbar";
117import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";126import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
118import PlanSidebar from "./PlanSidebar";127import PlanSidebar from "./PlanSidebar";
119import ThreadTerminalDrawer from "./ThreadTerminalDrawer";128import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
120import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";129import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
121import { RightPanelTabs } from "./RightPanelTabs";
122import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
123import { cn, randomHex } from "~/lib/utils";130import { cn, randomHex } from "~/lib/utils";
124import { stackedThreadToast, toastManager } from "./ui/toast";131import { stackedThreadToast, toastManager } from "./ui/toast";
125import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";132import { 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";
132import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";139import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
133import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";140import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
134import { useSettings } from "../hooks/useSettings";141import { useSettings } from "../hooks/useSettings";
135import { resolveAppModelSelectionForInstance } from "../modelSelection";142import { 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";
141import {
142 useSavedEnvironmentRegistryStore,
143 useSavedEnvironmentRuntimeStore,
144} from "../environments/runtime/catalog";
145import { reconnectSavedEnvironment } from "../environments/runtime/service";
146import { buildDraftThreadRouteParams } from "../threadRoutes";148import { buildDraftThreadRouteParams } from "../threadRoutes";
147import {149import {
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";
159import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
160import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState";
161import {161import {
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";
166import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation";166import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation";
167import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext";167import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext";
168import { environmentCatalog } from "../connection/catalog";
169import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
170import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
171import { projectEnvironment } from "../state/projects";
172import { useEnvironmentQuery } from "../state/query";
173import {
174 primaryServerAvailableEditorsAtom,
175 primaryServerKeybindingsAtom,
176 serverEnvironment,
177} from "../state/server";
178import { terminalEnvironment } from "../state/terminal";
179import { threadEnvironment } from "../state/threads";
180import { vcsEnvironment } from "../state/vcs";
181import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
182import {
183 useProject,
184 useProjects,
185 useThread,
186 useThreadProposedPlans,
187 useThreadRefs,
188} from "../state/entities";
189import { environmentShell } from "../state/shell";
168import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";190import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
169import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";191import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
170import { PullRequestThreadDialog } from "./PullRequestThreadDialog";192import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
...@@ -173,11 +195,12 @@ import { ChatHeader } from "./chat/ChatHeader";...@@ -173,11 +195,12 @@ import { ChatHeader } from "./chat/ChatHeader";
173import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";195import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
174import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";196import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
175import { NoActiveThreadState } from "./NoActiveThreadState";197import { NoActiveThreadState } from "./NoActiveThreadState";
176import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";198import { resolveEffectiveEnvMode } from "./BranchToolbar.logic";
177import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";199import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
178import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";200import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
179import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";201import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
180import {202import {
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";
201import { useLocalStorage } from "~/hooks/useLocalStorage";224import { useLocalStorage } from "~/hooks/useLocalStorage";
202import { useComposerHandleContext } from "../composerHandleContext";225import { useComposerHandleContext } from "../composerHandleContext";
203import {
204 useServerAvailableEditors,
205 useServerConfig,
206 useServerKeybindings,
207} from "~/rpc/serverState";
208import { sanitizeThreadErrorMessage } from "~/rpc/transportError";226import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
209import { retainThreadDetailSubscription } from "../environments/runtime/service";
210import { RightPanelSheet } from "./RightPanelSheet";227import { RightPanelSheet } from "./RightPanelSheet";
228import { previewEnvironment } from "../state/preview";
229import { useAtomCommand } from "../state/use-atom-command";
211import { Button } from "./ui/button";230import { Button } from "./ui/button";
212import {231import {
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";
237import { useAssetUrls } from "../assets/assetUrls";
218238
219const IMAGE_ONLY_BOOTSTRAP_PROMPT =239const 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).]";
221const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];241const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
222const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
223const EMPTY_PROVIDERS: ServerProvider[] = [];242const EMPTY_PROVIDERS: ServerProvider[] = [];
224const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];243const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
225const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};244const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
245const PreviewPanel = lazy(() =>
246 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
247);
248const DiffPanel = lazy(() => import("./DiffPanel"));
249const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel"));
250const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet<string> = new Set();
226const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [251const 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 = [
255type EnvironmentUnavailableState = {280type 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};
260285
261type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;286type 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}
282307
283function 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
396function formatOutgoingPrompt(params: {308function 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);
445357
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 );
486383 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 );
493399
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}
502408
...@@ -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 = serverThread480 const projectRef = serverThread
574 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)481 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
575 : draftThread482 : 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 (project588 (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 project598 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 );
713620
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.terminal656 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 ]);
772677
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 ]);
804706
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
812714
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 });
820722
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 })();
835736
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 );
841749
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 }
855763
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 <ThreadTerminalDrawer766 <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});
891798
892interface PersistentThreadTerminalPanelProps {799interface 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 = serverThread836 const projectRef = serverThread
930 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)837 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
931 : draftThread838 : 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 (project856 (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 project866 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 (project902 (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 ]);
1020927
1021 if (!project || !cwd) {928 if (!project || !cwd) return null;
1022 return null;
1023 }
1024929
1025 return (930 return (
1026 <ThreadTerminalDrawer931 <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 );
12111181
1212 const fallbackDraftProjectRef = draftThread1182 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" && serverThread1187 routeKind === "server" && serverThread
1220 ? null1188 ? 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 draftThread1193 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;
13411283
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]);
13481290
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 = activeThread1342 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 = activeProject1350 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) {
14181381
1419 useEffect(() => {1382 useEffect(() => {
1420 if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return;1383 if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return;
1421 useRightPanelStore1384 useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null);
1422 .getState()
1423 .reconcileFileSurfaces(activeThreadRef, activeProject !== undefined);
1424 }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]);1385 }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]);
14251386
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 to1387 // 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 }
14681401
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;
15591458
1560 const openPullRequestDialog = useCallback(1459 const openPullRequestDialog = useCallback(
...@@ -1664,24 +1563,21 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1664,24 +1563,21 @@ function ChatViewContent(props: ChatViewProps) {
16641563
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;
16731570
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 ]);
16861582
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 && activeThread1596 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 <Button1628 <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 <Button1639 <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 }
20431887
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 );
20451894
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 }
20521901
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 === 01970 Object.keys(attachmentPreviewHandoffByMessageId).length === 0
2128 ? messages1971 ? 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) {
22242067
2225 const gitCwd = activeProject2068 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 rather2085 // `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 no2086 // 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 ]);
24012170
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 );
24172176
2418 // Handle environment change for draft threads. When the user picks a2177 // 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 );
24712238
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 useRightPanelStore2272 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.terminal2387 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.terminal2393 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) {
27752452
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 }
28092486
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 );
28582534
...@@ -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({
28702546 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 }
28772557
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 });
28822562
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.runOnWorktreeCreate2592 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];
29172600
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 }
29362624
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 === scriptId2633 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 );
29552639
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);
29712657
2972 const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name;2658 const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name;
29732659
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 );
30182706
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 }
31312993
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?.preview2997 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?.terminal3004 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 }
32723138
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 }
32873161
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 }
32973178
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 );
33103202
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 transitively3237 // activeThreadRef resets transitively with the active thread.
3238 // eslint-disable-next-line react-hooks/exhaustive-deps
3346 }, [activeThread?.id]);3239 }, [activeThread?.id]);
33473240
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) {
35293422
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) {
36713567
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;
36773572
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) {
36993594
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 );
37303626
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();
39213817
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 );
39493843
3950 // Auto-title from first message3844 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 }
39593858
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 }
39693876
3970 const turnAttachments = await turnAttachmentsPromise;3877 let turnStartSucceeded = false;
3878 if (failure === null && turnAttachmentsResult._tag === "Success") {
3971 const bootstrap =3879 const bootstrap =
3972 isLocalDraftThread || baseBranchForWorktree3880 isLocalDraftThread || baseBranchForWorktree
3973 ? {3881 ? {
...@@ -3988,7 +3896,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3988,7 +3896,7 @@ function ChatViewContent(props: ChatViewProps) {
3988 ...(baseBranchForWorktree3896 ...(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 };
40663983
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 };
40774000
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;
40824004
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.orchestration4008 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 );
41054028
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;
41104032
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.orchestration4036 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 );
41334056
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 ]);
43084231
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;
43174241
4242 if (failure === null) {
4318 // Keep the mode toggle and plan-follow-up banner in sync immediately4243 // 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 );
43244249
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 produces4280 // "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 );
43884323
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 };
44374370
4438 await api.orchestration4371 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.orchestration4433 }
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 Error4455 error instanceof Error
4502 ? err.message4456 ? 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) {
46884646
4689 const panelToggleControls = (4647 const panelToggleControls = (
4690 <PanelLayoutControls4648 <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 <div4746 <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>
48324790
4833 {/* Error banner */}4791 {/* Error banner */}
4834 <ProviderStatusBanner status={activeProviderStatus} />4792 <ProviderStatusBanner status={activeProviderStatus} />
4835 <ThreadErrorBanner4793 <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 open4957 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 <PersistentThreadTerminalDrawer4976 <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>
50324994
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 <RightPanelTabs5023 <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}
50885049
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};
121121
apps/web/src/components/CommandPalette.tsx+270-267
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1"use client";1"use client";
22
3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
4import {
5 isAtomCommandInterrupted,
6 settlePromise,
7 squashAtomCommandFailure,
8} from "@t3tools/client-runtime/state/runtime";
4import {9import {
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";
14import { useQuery, useQueryClient } from "@tanstack/react-query";
15import { useNavigate, useParams } from "@tanstack/react-router";19import { useNavigate, useParams } from "@tanstack/react-router";
16import * as Option from "effect/Option";20import * as Option from "effect/Option";
17import {21import {
...@@ -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";
40import { useShallow } from "zustand/react/shallow";45import { useAtomValue } from "@effect/atom-react";
41import { useCommandPaletteStore } from "../commandPaletteStore";46import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext";
42import { readEnvironmentApi } from "../environmentApi";
43import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary";
44import {
45 useSavedEnvironmentRegistryStore,
46 useSavedEnvironmentRuntimeStore,
47} from "../environments/runtime";
48import { useHandleNewThread } from "../hooks/useHandleNewThread";47import { useHandleNewThread } from "../hooks/useHandleNewThread";
49import { useSettings } from "../hooks/useSettings";48import { useSettings } from "../hooks/useSettings";
50import { readLocalApi } from "../localApi";49import { readLocalApi } from "../localApi";
51import {50import { filesystemEnvironment } from "../state/filesystem";
52 getSourceControlDiscoverySnapshot,51import { projectEnvironment } from "../state/projects";
53 refreshSourceControlDiscovery,52import { useEnvironmentQuery } from "../state/query";
54} from "../lib/sourceControlDiscoveryState";53import { sourceControlEnvironment } from "../state/sourceControl";
54import { useAtomCommand } from "../state/use-atom-command";
55import { useAtomQueryRunner } from "../state/use-atom-query-runner";
56import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
57import { useProjects, useThreadShells } from "../state/entities";
55import {58import {
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";
74import { isTerminalFocused } from "../lib/terminalFocus";77import { isTerminalFocused } from "../lib/terminalFocus";
75import { getLatestThreadForProject } from "../lib/threadSort";78import { getLatestThreadForProject } from "../lib/threadSort";
76import { cn, isMacPlatform, isWindowsPlatform, newCommandId, newProjectId } from "../lib/utils";79import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";
77import {
78 selectProjectsAcrossEnvironments,
79 selectSidebarThreadsAcrossEnvironments,
80 useStore,
81} from "../store";
82import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";80import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
83import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";81import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
84import {82import {
...@@ -102,7 +100,7 @@ import { CommandPaletteResults } from "./CommandPaletteResults";...@@ -102,7 +100,7 @@ import { CommandPaletteResults } from "./CommandPaletteResults";
102import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";100import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";
103import { ProjectFavicon } from "./ProjectFavicon";101import { ProjectFavicon } from "./ProjectFavicon";
104import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";102import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
105import { useServerKeybindings } from "../rpc/serverState";103import { primaryServerKeybindingsAtom } from "../state/server";
106import { resolveShortcutCommand } from "../keybindings";104import { resolveShortcutCommand } from "../keybindings";
107import {105import {
108 Command,106 Command,
...@@ -120,7 +118,6 @@ import { ComposerHandleContext, useComposerHandleContext } from "../composerHand...@@ -120,7 +118,6 @@ import { ComposerHandleContext, useComposerHandleContext } from "../composerHand
120import type { ChatComposerHandle } from "./chat/ChatComposer";118import type { ChatComposerHandle } from "./chat/ChatComposer";
121119
122const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];120const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
123const BROWSE_STALE_TIME_MS = 30_000;
124121
125function getLocalFileManagerName(platform: string): string {122function 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}
328325
326interface CommandPaletteOpenIntent {
327 readonly kind: "add-project";
328}
329
330interface CommandPaletteUiState {
331 readonly open: boolean;
332 readonly openIntent: CommandPaletteOpenIntent | null;
333}
334
335type CommandPaletteUiAction =
336 | { readonly _tag: "SetOpen"; readonly open: boolean }
337 | { readonly _tag: "Toggle" }
338 | { readonly _tag: "OpenAddProject" }
339 | { readonly _tag: "ClearOpenIntent" };
340
341function 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
329export function CommandPalette({ children }: { children: ReactNode }) {360export 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]);
365401
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}
375418
376function CommandPaletteDialog() {419function 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;
379422 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 }
389428
390 return <OpenCommandPaletteDialog />;429 return (
430 <OpenCommandPaletteDialog
431 openIntent={props.openIntent}
432 setOpen={props.setOpen}
433 clearOpenIntent={props.clearOpenIntent}
434 />
435 );
391}436}
392437
393function OpenCommandPaletteDialog() {438function 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);
424478
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";
428482 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 }
459492
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 });
466499
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 === primaryEnvironmentId506 const sourceControlDiscovery = useEnvironmentQuery(
479 ? (readPrimaryEnvironmentDescriptor()?.platform.os ?? null)507 browseEnvironmentId === null
480 : browseEnvironmentId508 ? null
481 ? (savedEnvironmentRuntimeById[browseEnvironmentId]?.descriptor?.platform.os ??509 : sourceControlEnvironment.discovery({
482 savedEnvironmentRuntimeById[browseEnvironmentId]?.serverConfig?.environment.platform510 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 === primaryEnvironmentId528 environment?.serverConfig?.settings ??
497 ? settings529 (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 );
509538
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 );
518548
...@@ -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) : "";
535565 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 );
569586
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 <ProjectFavicon623 <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 <ProjectFavicon641 <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 );
905871
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 });
10501016
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);
10531029
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;
10671041
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 }
11181105
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 }
11701175
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 }
12051198
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 }
12371235
...@@ -1271,23 +1269,27 @@ function OpenCommandPaletteDialog() {...@@ -1271,23 +1269,27 @@ function OpenCommandPaletteDialog() {
1271 }1269 }
12721270
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 }
12921294
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 <CommandDialogPopup1517 <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 @@
1import { useAtomValue } from "@effect/atom-react";
1import { Virtualizer } from "@pierre/diffs/react";2import { Virtualizer } from "@pierre/diffs/react";
2import { useNavigate, useParams, useSearch } from "@tanstack/react-router";3import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
3import { scopeThreadRef } from "@t3tools/client-runtime";4import { scopeThreadRef } from "@t3tools/client-runtime/environment";
5import {
6 isAtomCommandInterrupted,
7 squashAtomCommandFailure,
8} from "@t3tools/client-runtime/state/runtime";
4import type { ScopedThreadRef, TurnId } from "@t3tools/contracts";9import type { ScopedThreadRef, TurnId } from "@t3tools/contracts";
5import {10import {
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";
22import { openInPreferredEditor } from "../editorPreferences";27import { useOpenInPreferredEditor } from "../editorPreferences";
23import { type DraftId } from "../composerDraftStore";28import { type DraftId } from "../composerDraftStore";
29import { openDiffFilePrimaryAction } from "../diffFileActions";
24import { useCheckpointDiff } from "~/lib/checkpointDiffState";30import { useCheckpointDiff } from "~/lib/checkpointDiffState";
25import { useVcsStatus } from "~/lib/vcsStatusState";
26import { cn } from "~/lib/utils";31import { cn } from "~/lib/utils";
27import { openDiffFilePrimaryAction } from "../diffFileActions";
28import { readLocalApi } from "../localApi";
29import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";32import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
30import { useTheme } from "../hooks/useTheme";33import { useTheme } from "../hooks/useTheme";
31import {34import {
...@@ -36,8 +39,7 @@ import {...@@ -36,8 +39,7 @@ import {
36 resolveFileDiffPath,39 resolveFileDiffPath,
37} from "../lib/diffRendering";40} from "../lib/diffRendering";
38import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";41import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
39import { selectProjectByRef, useStore } from "../store";42import { useProject, useThread } from "../state/entities";
40import { createThreadSelectorByRef } from "../storeSelectors";
41import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";43import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
42import { useSettings } from "../hooks/useSettings";44import { useSettings } from "../hooks/useSettings";
43import { formatShortTimestamp } from "../timestampFormat";45import { 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
45import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff";47import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff";
46import { ToggleGroup, Toggle } from "./ui/toggle-group";48import { ToggleGroup, Toggle } from "./ui/toggle-group";
47import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";49import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
50import { useEnvironmentQuery } from "../state/query";
51import { serverEnvironment } from "../state/server";
52import { vcsEnvironment } from "../state/vcs";
4853
49type DiffRenderMode = "stacked" | "split";54type DiffRenderMode = "stacked" | "split";
50type DiffThemeType = "light" | "dark";55type DiffThemeType = "light" | "dark";
5156
57interface CollapsedDiffFilesState {
58 readonly scopeKey: string | null;
59 readonly fileKeys: ReadonlySet<string>;
60}
61
62const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet<string> = new Set();
63
52const DIFF_PANEL_UNSAFE_CSS = `64const 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 && activeProjectId195 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 = selectedTurn254 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]);
306335
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 }, []);
366386
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 @@
1import "../index.css";
2
3import { describe, expect, it } from "vite-plus/test";
4import { render } from "vitest-browser-react";
5
6import { DiffPanelShell } from "./DiffPanelShell";
7
8describe("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 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";
2import { ThreadId } from "@t3tools/contracts";
3import { useState } from "react";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5import { render } from "vitest-browser-react";
6
7const SHARED_THREAD_ID = ThreadId.make("thread-shared");
8const ENVIRONMENT_A = "environment-local" as never;
9const ENVIRONMENT_B = "environment-remote" as never;
10const GIT_CWD = "/repo/project";
11const BRANCH_NAME = "feature/toast-scope";
12
13function 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
25const {
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
53vi.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
63vi.mock("~/editorPreferences", () => ({
64 openInPreferredEditor: vi.fn(),
65}));
66
67vi.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
96vi.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
123vi.mock("~/localApi", () => ({
124 ensureLocalApi: vi.fn(() => {
125 throw new Error("ensureLocalApi not implemented in browser test");
126 }),
127 readLocalApi: vi.fn(() => null),
128}));
129
130vi.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
160vi.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
237vi.mock("~/terminal-links", () => ({
238 resolvePathLinkTarget: vi.fn(),
239}));
240
241import GitActionsControl from "./GitActionsControl";
242
243function 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
249function 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
267describe("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 @@
1import { useAtomValue } from "@effect/atom-react";
1import { type ScopedThreadRef } from "@t3tools/contracts";2import { type ScopedThreadRef } from "@t3tools/contracts";
3import {
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
2import type {7import 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";
63import { Textarea } from "~/components/ui/textarea";68import { Textarea } from "~/components/ui/textarea";
64import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast";69import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast";
65import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";70import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
66import { openInPreferredEditor } from "~/editorPreferences";71import { useOpenInPreferredEditor } from "~/editorPreferences";
67import {72import {
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";
74import { getVcsStatusDataForTarget, refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState";79import { useThread } from "~/state/entities";
75import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState";80import { useEnvironmentQuery } from "~/state/query";
76import { newCommandId, randomUUID } from "~/lib/utils";81import { serverEnvironment } from "~/state/server";
82import { sourceControlEnvironment } from "~/state/sourceControl";
83import { threadEnvironment } from "~/state/threads";
84import { useAtomCommand } from "~/state/use-atom-command";
85import { vcsEnvironment } from "~/state/vcs";
86import { randomUUID } from "~/lib/utils";
77import { resolvePathLinkTarget } from "~/terminal-links";87import { resolvePathLinkTarget } from "~/terminal-links";
78import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";88import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
79import { readEnvironmentApi } from "~/environmentApi";
80import { readLocalApi } from "~/localApi";89import { readLocalApi } from "~/localApi";
81import { getSourceControlPresentation } from "~/sourceControlPresentation";90import { getSourceControlPresentation } from "~/sourceControlPresentation";
82import { useStore } from "~/store";
83import { createThreadSelectorByRef } from "~/storeSelectors";
8491
85interface GitActionsControlProps {92interface GitActionsControlProps {
86 gitCwd: string | null;93 gitCwd: string | null;
...@@ -128,6 +135,22 @@ interface RunGitActionWithToastInput {...@@ -128,6 +135,22 @@ interface RunGitActionWithToastInput {
128}135}
129136
130const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;137const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;
138
139type RefreshVcsStatus = (target: {
140 readonly environmentId: ScopedThreadRef["environmentId"];
141 readonly input: { readonly cwd: string };
142}) => Promise<unknown>;
143
144function 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}
131const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;154const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
132155
133const PUBLISH_PROVIDER_OPTIONS = [156const PUBLISH_PROVIDER_OPTIONS = [
...@@ -348,9 +371,17 @@ interface PublishRepositoryDialogProps {...@@ -348,9 +371,17 @@ interface PublishRepositoryDialogProps {
348371
349function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {372function 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;
429467
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]);
446477
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) {
466482
467 setPublishError(null);483 setPublishError(null);
468484
469 void publishRepositoryAction485 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) {
500518
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 <RadioGroup612 <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 draftId989 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 }
10111034
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.orchestration1038 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 }
10251044
1026 setThreadBranch(activeThreadRef, branch, worktreePath);
1027 return;1045 return;
1028 }1046 }
10291047
...@@ -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 );
10481066
...@@ -1058,13 +1076,18 @@ export default function GitActionsControl({...@@ -1058,13 +1076,18 @@ export default function GitActionsControl({
1058 [persistThreadBranchSync],1076 [persistThreadBranchSync],
1059 );1077 );
10601078
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]);
11911212
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 a1380 // 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 };
13661387
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 });
13751396
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 }
14101403
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({
15761619
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 );
16021649
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 <Menu1725 <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 @@
1import "../index.css";
2
3import {
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";
19import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
20import { ws, http, HttpResponse } from "msw";
21import { setupWorker } from "msw/browser";
22import * as Schema from "effect/Schema";
23import {
24 afterAll,
25 afterEach,
26 beforeAll,
27 beforeEach,
28 describe,
29 expect,
30 it,
31 vi,
32} from "vite-plus/test";
33import { render } from "vitest-browser-react";
34
35import { useComposerDraftStore } from "../composerDraftStore";
36import { __resetLocalApiForTests } from "../localApi";
37import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
38import { getServerConfig, getServerConfigUpdatedNotification } from "../rpc/serverState";
39import { getWsConnectionStatus } from "../rpc/wsConnectionState";
40import { getRouter } from "../router";
41import { useStore } from "../store";
42import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers";
43import { BrowserWsRpcHarness } from "../../test/wsRpcHarness";
44
45vi.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
79const THREAD_ID = "thread-kb-toast-test" as ThreadId;
80const PROJECT_ID = "project-1" as ProjectId;
81const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
82const NOW_ISO = "2026-03-04T12:00:00.000Z";
83
84interface TestFixture {
85 snapshot: OrchestrationReadModel;
86 serverConfig: ServerConfig;
87 welcome: ServerLifecycleWelcomePayload;
88}
89
90let fixture: TestFixture;
91const rpcHarness = new BrowserWsRpcHarness();
92const encodeServerConfig = Schema.encodeSync(ServerConfigSchema);
93const encodeServerSettings = Schema.encodeSync(ServerSettings);
94
95const wsLink = ws.link(/ws(s)?:\/\/.*/);
96
97function 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
175function 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
240function 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
277function 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
297function 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
316const 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
329function 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
337function queryToastTitles(): string[] {
338 return Array.from(document.querySelectorAll('[data-slot="toast-title"]')).map(
339 (el) => el.textContent ?? "",
340 );
341}
342
343async 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
358async function waitForComposerEditor(): Promise<HTMLElement> {
359 return waitForElement(
360 () => document.querySelector<HTMLElement>('[data-testid="composer-editor"]'),
361 "App should render composer editor",
362 );
363}
364
365async 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
372async 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
381async 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
391async 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
400async function waitForNoToasts(): Promise<void> {
401 await vi.waitFor(
402 () => {
403 expect(queryToastTitles()).toHaveLength(0);
404 },
405 { timeout: 8_000, interval: 16 },
406 );
407}
408
409async 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
423async function waitForServerConfigSnapshot(): Promise<void> {
424 await vi.waitFor(
425 () => {
426 expect(getServerConfig()).not.toBeNull();
427 },
428 { timeout: 8_000, interval: 16 },
429 );
430}
431
432async 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
459async 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
495describe("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 @@
1import type { ServerConfigStreamEvent } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 createKeybindingsUpdateToastController,
6 KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS,
7} from "./KeybindingsUpdateToast.logic";
8
9function 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
23describe("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 @@
1import type { ServerConfigStreamEvent } from "@t3tools/contracts";
2
3export const KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS = 2_000;
4
5export type KeybindingsUpdateToastDecision =
6 | { readonly _tag: "Success" }
7 | { readonly _tag: "InvalidConfiguration"; readonly message: string };
8
9export interface KeybindingsUpdateToastController {
10 readonly handle: (event: ServerConfigStreamEvent | null) => KeybindingsUpdateToastDecision | null;
11}
12
13export 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 @@
1import { memo, useState, useCallback } from "react";1import { memo, useState, useCallback } from "react";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";6import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
3import { type TimestampFormat } from "@t3tools/contracts/settings";7import { type TimestampFormat } from "@t3tools/contracts/settings";
4import { Badge } from "./ui/badge";8import { Badge } from "./ui/badge";
...@@ -24,9 +28,10 @@ import {...@@ -24,9 +28,10 @@ import {
24 stripDisplayedPlanMarkdown,28 stripDisplayedPlanMarkdown,
25} from "../proposedPlan";29} from "../proposedPlan";
26import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";30import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
27import { readEnvironmentApi } from "~/environmentApi";31import { projectEnvironment } from "~/state/projects";
28import { stackedThreadToast, toastManager } from "./ui/toast";32import { stackedThreadToast, toastManager } from "./ui/toast";
29import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";33import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
34import { useAtomCommand } from "~/state/use-atom-command";
3035
31function stepStatusIcon(status: string): React.ReactNode {36function 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();
7987
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]);
94102
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.projects107 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]);
127137
128 return (138 return (
129 <div139 <div
apps/web/src/components/ProjectFavicon.tsx+23-19
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import { FolderIcon } from "lucide-react";2import { FolderIcon } from "lucide-react";
3import { useEffect, useState } from "react";3import { useState } from "react";
4import { useAssetUrl } from "../assets/assetUrls";4import { useAssetUrl } from "../assets/assetUrls";
55
6const loadedProjectFaviconSrcs = new Set<string>();6const loadedProjectFaviconSrcs = new Set<string>();
...@@ -8,38 +8,42 @@ const loadedProjectFaviconSrcs = new Set<string>();...@@ -8,38 +8,42 @@ const loadedProjectFaviconSrcs = new Set<string>();
8export function ProjectFavicon(input: {8export 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]);
2317
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 }
3121
22 return <ProjectFaviconImage key={src} src={src} className={input.className} />;
23}
24
25function ProjectFaviconFallback({ className }: { readonly className?: string | undefined }) {
26 return <FolderIcon className={`size-3.5 shrink-0 text-muted-foreground/50 ${className ?? ""}`} />;
27}
28
29function 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 <img43 <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";
6import {
7 isAtomCommandInterrupted,
8 squashAtomCommandFailure,
9 type AtomCommandResult,
10} from "@t3tools/client-runtime/state/runtime";
6import {11import {
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}
9398
99export type ProjectScriptActionResult = AtomCommandResult<void, unknown>;
100
94interface ProjectScriptsControlProps {101interface 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}
103113
104export default function ProjectScriptsControl({114export default function ProjectScriptsControl({
...@@ -161,6 +171,7 @@ export default function ProjectScriptsControl({...@@ -161,6 +171,7 @@ export default function ProjectScriptsControl({
161 }171 }
162172
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 };
196214
197 const openAddDialog = () => {215 const openAddDialog = () => {
apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts+9-14
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";2import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/unstable/reactivity";
35
4import {6import {
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 });
436435
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 ];
441438
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 ];
456451
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";
8import {
9 squashAtomCommandFailure,
10 type AtomCommandResult,
11} from "@t3tools/client-runtime/state/runtime";
812
9export type ProviderUpdateCandidate = ServerProvider & {13export 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(
328332
329export function collectUpdatedProviderSnapshots(input: {333export 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[] = [];
336340
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}
350354
351export function firstRejectedProviderUpdateMessage(355export 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}
360365
361function getUpdateFinishedAt(provider: ServerProvider): string | null {366function getUpdateFinishedAt(provider: ServerProvider): string | null {
apps/web/src/components/ProviderUpdateLaunchNotification.tsx+31-17
...@@ -1,17 +1,18 @@...@@ -1,17 +1,18 @@
1import { useNavigate } from "@tanstack/react-router";1import { useNavigate } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";
2import { DownloadIcon } from "lucide-react";3import { DownloadIcon } from "lucide-react";
3import { useCallback, useEffect, useMemo, useRef } from "react";4import { useCallback, useEffect, useMemo, useRef } from "react";
4import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";5import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
56
6import { ensureLocalApi } from "../localApi";7import { primaryServerProvidersAtom, serverEnvironment } from "../state/server";
8import { usePrimaryEnvironment } from "../state/environments";
7import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";9import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";
8import { useServerProviders } from "../rpc/serverState";
9import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils";10import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils";
10import {11import {
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";
22import { stackedThreadToast, toastManager } from "./ui/toast";23import { stackedThreadToast, toastManager } from "./ui/toast";
24import { useAtomCommand } from "../state/use-atom-command";
2325
24const seenProviderUpdateNotificationKeys = new Set<string>();26const seenProviderUpdateNotificationKeys = new Set<string>();
25type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;27type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;
...@@ -101,7 +103,11 @@ function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) {...@@ -101,7 +103,11 @@ function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) {
101103
102export function ProviderUpdateLaunchNotification() {104export 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 };
186192
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 });
208214
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 }
221233
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 };
252264
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 ]);
298312
apps/web/src/components/PullRequestThreadDialog.tsx+28-25
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
2import { useDebouncedValue } from "@tanstack/react-pacer";3import { useDebouncedValue } from "@tanstack/react-pacer";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
45
...@@ -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";
10import { useVcsStatus } from "~/lib/vcsStatusState";
11import { cn } from "~/lib/utils";11import { cn } from "~/lib/utils";
12import { parsePullRequestReference } from "~/pullRequestReference";12import { parsePullRequestReference } from "~/pullRequestReference";
13import { getSourceControlPresentation } from "~/sourceControlPresentation";13import { getSourceControlPresentation } from "~/sourceControlPresentation";
14import { useEnvironmentQuery } from "~/state/query";
15import { vcsEnvironment } from "~/state/vcs";
14import { Button } from "./ui/button";16import { Button } from "./ui/button";
15import {17import {
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;
6271
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.error180 (resolvedPullRequest === null && pullRequestResolution.error
176 ? pullRequestResolution.error instanceof Error181 ? pullRequestResolution.error
177 ? pullRequestResolution.error.message
178 : `Failed to resolve ${terminology.singular}.`
179 : preparePullRequestThreadAction.error instanceof Error182 : preparePullRequestThreadAction.error instanceof Error
180 ? preparePullRequestThreadAction.error.message183 ? preparePullRequestThreadAction.error.message
181 : preparePullRequestThreadAction.error184 : preparePullRequestThreadAction.error
apps/web/src/components/Sidebar.dblclick.browser.tsx deleted-255
...@@ -1,255 +0,0 @@
1import "../index.css";
2
3import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
4import { useCallback, useRef, useState } from "react";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6import { page, userEvent } from "vite-plus/test/browser";
7import { cleanup, render } from "vitest-browser-react";
8
9import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
10import { DEFAULT_INTERACTION_MODE } from "../types";
11import type { SidebarThreadSummary } from "../types";
12import { 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.
16vi.mock("~/hooks/useMediaQuery", () => ({
17 useIsMobile: () => false,
18 useMediaQuery: () => false,
19}));
20
21const THREAD_ID = ThreadId.make("thread-1");
22const ENVIRONMENT_ID = EnvironmentId.make("environment-local");
23const PROJECT_ID = ProjectId.make("project-1");
24const INITIAL_TITLE = "Original title";
25
26const ROW_TESTID = `thread-row-${THREAD_ID}`;
27const 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.
31const 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
43function 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.
67function 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
135describe("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 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { ProviderDriverKind } from "@t3tools/contracts";
3
4import {2import {
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});
7082
71describe("createThreadJumpHintVisibilityController", () => {83describe("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 });
367379
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});
375406
376describe("resolveAdjacentThreadId", () => {407describe("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 };
510544
...@@ -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 });
558592
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 });
574608
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", () => {
834describe("sortProjectsForSidebar", () => {868describe("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", () => {
929965
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 ];
935971
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;
18export type SidebarNewThreadEnvMode = "local" | "worktree";18export type SidebarNewThreadEnvMode = "local" | "worktree";
19type SidebarProject = {19type 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;
152152
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 }
234235
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}
252263
...@@ -367,7 +378,7 @@ export function resolveThreadStatusPill(input: {...@@ -367,7 +378,7 @@ export function resolveThreadStatusPill(input: {
367 };378 };
368 }379 }
369380
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";
21import { ProjectFavicon } from "./ProjectFavicon";21import { ProjectFavicon } from "./ProjectFavicon";
22import { useAtomValue } from "@effect/atom-react";
22import { autoAnimate } from "@formkit/auto-animate";23import { autoAnimate } from "@formkit/auto-animate";
23import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";24import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
24import { useShallow } from "zustand/react/shallow";25import { useShallow } from "zustand/react/shallow";
...@@ -39,9 +40,9 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-...@@ -39,9 +40,9 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-
39import { CSS } from "@dnd-kit/utilities";40import { CSS } from "@dnd-kit/utilities";
40import {41import {
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";
57import {
58 isAtomCommandInterrupted,
59 settlePromise,
60 squashAtomCommandFailure,
61} from "@t3tools/client-runtime/state/runtime";
56import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";62import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
57import {63import {
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";
64import { usePrimaryEnvironmentId } from "../environments/primary";
65import { isElectron } from "../env";70import { isElectron } from "../env";
66import { APP_STAGE_LABEL, APP_VERSION } from "../branding";71import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
67import { isTerminalFocused } from "../lib/terminalFocus";72import { isTerminalFocused } from "../lib/terminalFocus";
68import { isMacPlatform, newCommandId } from "../lib/utils";73import { isMacPlatform } from "../lib/utils";
69import {74import {
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";
77import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";81import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
78import { useThreadRunningTerminalIds } from "../terminalSessionState";82import { useThreadRunningTerminalIds } from "../state/terminalSessions";
79import { useThreadDiscoveredPorts } from "../portDiscoveryState";83import { useThreadDiscoveredPorts } from "../portDiscoveryState";
80import { useUiStateStore } from "../uiStateStore";84import { openDiscoveredPort } from "./preview/openDiscoveredPort";
85import { useAtomCommand } from "../state/use-atom-command";
86import { previewEnvironment } from "../state/preview";
87import {
88 legacyProjectCwdPreferenceKey,
89 resolveProjectExpanded,
90 useUiStateStore,
91} from "../uiStateStore";
81import {92import {
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";
89import { useModelPickerOpen } from "../modelPickerOpenState";100import { isModelPickerOpen } from "../modelPickerVisibility";
90import { useShortcutModifierState } from "../shortcutModifierState";101import { useShortcutModifierState } from "../shortcutModifierState";
91import { useVcsStatus } from "../lib/vcsStatusState";
92import { readLocalApi } from "../localApi";102import { readLocalApi } from "../localApi";
93import { useComposerDraftStore } from "../composerDraftStore";103import { useComposerDraftStore } from "../composerDraftStore";
94import { useNewThreadHandler } from "../hooks/useHandleNewThread";104import { useNewThreadHandler } from "../hooks/useHandleNewThread";
95import { retainThreadDetailSubscription } from "../environments/runtime/service";105import { useDesktopUpdateState } from "../state/desktopUpdate";
96106
97import { useThreadActions } from "../hooks/useThreadActions";107import { useThreadActions } from "../hooks/useThreadActions";
108import { projectEnvironment } from "../state/projects";
109import { useEnvironmentQuery } from "../state/query";
110import { threadEnvironment, useEnvironmentThread } from "../state/threads";
111import { vcsEnvironment } from "../state/vcs";
112import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
98import {113import {
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";
161import { useThreadSelectionStore } from "../threadSelectionStore";176import { useThreadSelectionStore } from "../threadSelectionStore";
162import { useCommandPaletteStore } from "../commandPaletteStore";177import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
163import {178import {
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";
181import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";196import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
182import { useIsMobile } from "~/hooks/useMediaQuery";197import { useIsMobile } from "~/hooks/useMediaQuery";
183import { CommandDialogTrigger } from "./ui/command";198import { CommandDialogTrigger } from "./ui/command";
184import { readEnvironmentApi } from "../environmentApi";
185import { useSettings, useUpdateSettings } from "~/hooks/useSettings";199import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
186import { useServerKeybindings } from "../rpc/serverState";200import { primaryServerKeybindingsAtom } from "../state/server";
187import {201import {
188 derivePhysicalProjectKey,202 derivePhysicalProjectKey,
189 deriveProjectGroupingOverrideKey,203 deriveProjectGroupingOverrideKey,
190 getProjectOrderKey,204 getProjectOrderKey,
191 selectProjectGroupingSettings,205 selectProjectGroupingSettings,
192} from "../logicalProject";206} from "../logicalProject";
193import {
194 useSavedEnvironmentRegistryStore,
195 useSavedEnvironmentRuntimeStore,
196} from "../environments/runtime";
197import type { SidebarThreadSummary } from "../types";207import type { SidebarThreadSummary } from "../types";
198import {208import {
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";
204import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";214import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";
205import { openDiscoveredPort } from "./preview/openDiscoveredPort";
206const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {215const 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> =
225const SIDEBAR_ICON_ACTION_BUTTON_CLASS =234const 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";
227236
237function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) {
238 useEnvironmentThread(threadRef.environmentId, threadRef.threadId);
239 return null;
240}
241
228function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount {242function 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 }
242256
243 return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd;257 return member.environmentLabel
258 ? `${member.environmentLabel} — ${member.workspaceRoot}`
259 : member.workspaceRoot;
260}
261
262function 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}
245269
246function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string {270function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string {
...@@ -255,7 +279,7 @@ function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): strin...@@ -255,7 +279,7 @@ function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): strin
255}279}
256280
257function buildThreadJumpLabelMap(input: {281function 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 environment397 // For grouped projects, the thread may belong to a different environment
377 // than the representative project. Look up the thread's own project cwd398 // 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 the485 // 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 }
482534
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 );
13271395
...@@ -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 );
13391407
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
13571425
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 }, []);
13621430
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 );
13731441
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);
13831461 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 );
13981465
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 });
14231490
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 > 01499 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.environmentLabel1505 ...(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.environmentLabel1515 ...(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 }
14541524
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 }
14781561
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 }
14891572
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 !== undefined1886 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
18381962
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 );
19162036
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 }
19352055
1936 if (trimmed === projectRenameTarget.name) {2056 if (trimmed === projectRenameTarget.title) {
1937 closeProjectRenameDialog();2057 closeProjectRenameDialog();
1938 return;2058 return;
1939 }2059 }
19402060
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]);
19712081
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 {projectRenameTarget2350 {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 {projectGroupingTarget2397 {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});
28943015
2895export default function Sidebar() {3016export 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]);
29433078
...@@ -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 ]);
29823107
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: routeThreadRef3159 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 );
31093229
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 && activeThreadKey3314 !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: routeThreadRef3365 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 );
32863401
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]);
33843487
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 = desktopUpdateState3489 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() {
35203590
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} />
35243597
3525 {isOnSettings ? (3598 {isOnSettings ? (
apps/web/src/components/SlowRpcRequestToastCoordinator.tsx created+73
...@@ -0,0 +1,73 @@
1import { useEffect, useRef } from "react";
2
3import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState";
4import { toastManager } from "./ui/toast";
5
6function 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
13function 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
31export 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 @@
1import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";1import {
2 scopeProjectRef,
3 scopedThreadKey,
4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";
2import type { VcsStatusResult } from "@t3tools/contracts";6import type { VcsStatusResult } from "@t3tools/contracts";
3import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react";7import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
4import { useMemo } from "react";8import { useMemo } from "react";
5import { usePrimaryEnvironmentId } from "../environments/primary";9import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
6import {10import { useProject } from "../state/entities";
7 useSavedEnvironmentRegistryStore,11import { useEnvironmentQuery } from "../state/query";
8 useSavedEnvironmentRuntimeStore,12import { useThreadRunningTerminalIds } from "../state/terminalSessions";
9} from "../environments/runtime";13import { vcsEnvironment } from "../state/vcs";
10import { useVcsStatus } from "../lib/vcsStatusState";
11import { type AppState, selectProjectByRef, useStore } from "../store";
12import { useThreadRunningTerminalIds } from "../terminalSessionState";
13import { useUiStateStore } from "../uiStateStore";14import { useUiStateStore } from "../uiStateStore";
14import { resolveChangeRequestPresentation } from "../sourceControlPresentation";15import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
15import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";16import { 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);
228226
229 if (!terminalStatus && !isRemoteThread) {227 if (!terminalStatus && !isRemoteThread) {
apps/web/src/components/ThreadTerminalDrawer.browser.tsx deleted-425
...@@ -1,425 +0,0 @@
1import "../index.css";
2
3import { scopeThreadRef } from "@t3tools/client-runtime";
4import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts";
5import { afterEach, describe, expect, it, vi } from "vite-plus/test";
6import { render } from "vitest-browser-react";
7
8const {
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
46vi.mock("@xterm/addon-fit", () => ({
47 FitAddon: class MockFitAddon {
48 fit = fitAddonFitSpy;
49 },
50}));
51
52vi.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
121vi.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
132vi.mock("~/localApi", () => ({
133 ensureLocalApi: vi.fn(() => {
134 throw new Error("ensureLocalApi not implemented in browser test");
135 }),
136 readLocalApi: readLocalApiMock,
137}));
138
139import { TerminalViewport } from "./ThreadTerminalDrawer";
140
141const THREAD_ID = ThreadId.make("thread-terminal-browser");
142
143function 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
177async 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
247describe("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 @@
1import { useAtomValue } from "@effect/atom-react";
1import { FitAddon } from "@xterm/addon-fit";2import { FitAddon } from "@xterm/addon-fit";
2import {3import {
3 Globe2,4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7import {
4 Plus,8 Plus,
5 SquareSplitHorizontal,9 SquareSplitHorizontal,
6 SquareSplitVertical,10 SquareSplitVertical,
...@@ -11,8 +15,6 @@ import {...@@ -11,8 +15,6 @@ import {
11import {15import {
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";
18import { getTerminalLabel } from "@t3tools/shared/terminalLabels";20import { 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";
20import {22import {
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 {
30import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";33import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
31import { cn } from "~/lib/utils";34import { cn } from "~/lib/utils";
32import { type TerminalContextSelection } from "~/lib/terminalContext";35import { type TerminalContextSelection } from "~/lib/terminalContext";
33import { openInPreferredEditor } from "../editorPreferences";36import { useOpenInPreferredEditor } from "../editorPreferences";
34import {37import {
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";
58import { readEnvironmentApi } from "~/environmentApi";
59import { readLocalApi } from "~/localApi";61import { readLocalApi } from "~/localApi";
60import { attachTerminalSession } from "../terminalSessionState";62import { useAttachedTerminalSession } from "../state/terminalSessions";
63import { serverEnvironment } from "../state/server";
64import { previewEnvironment } from "../state/preview";
65import { terminalEnvironment } from "../state/terminal";
61import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";66import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
62import { useDiscoveredPorts } from "../portDiscoveryState";67import { useAtomCommand } from "../state/use-atom-command";
63import { openDiscoveredPort } from "./preview/openDiscoveredPort";
6468
65const MIN_DRAWER_HEIGHT = 180;69const MIN_DRAWER_HEIGHT = 180;
66const MAX_DRAWER_HEIGHT_RATIO = 0.75;70const 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}
8387
84function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnapshot): void {88function 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}
9094
...@@ -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 });
325376
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;
333384
334 let disposed = false;
335 const api = readEnvironmentApi(environmentId);
336 const localApi = readLocalApi();385 const localApi = readLocalApi();
337 if (!api) return;
338386
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({
352400
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 };
355409
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({
517571
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 }
524574
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 }
549601
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 });
562619
563 const inputDisposable = terminal.onData((data) => {620 const inputDisposable = terminal.onData((data) => {
564 void api.terminal621 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 });
573633
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 });
616676
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.terminal687 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);
766689
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-deps707 // eslint-disable-next-line react-hooks/exhaustive-deps
792 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);708 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
793709
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]);
805780
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.terminal791 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 }, []);
11281106
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]);
11351110
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 }, []);
11471122
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 );
11621140
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 <PopoverTrigger1482 <PopoverTrigger
apps/web/src/components/WebSocketConnectionSurface.logic.test.ts deleted-114
...@@ -1,114 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { WsConnectionStatus } from "../rpc/wsConnectionState";
4import { shouldAutoReconnect, shouldRestartStalledReconnect } from "./WebSocketConnectionSurface";
5
6function 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
28describe("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 @@
1import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
2
3import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState";
4import {
5 getWsConnectionStatus,
6 getWsConnectionUiState,
7 setBrowserOnlineStatus,
8 type WsConnectionStatus,
9 type WsConnectionUiState,
10 useWsConnectionStatus,
11 WS_RECONNECT_MAX_ATTEMPTS,
12} from "../rpc/wsConnectionState";
13import { stackedThreadToast, toastManager } from "./ui/toast";
14import { getPrimaryEnvironmentConnection } from "../environments/runtime";
15
16const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000;
17type WsAutoReconnectTrigger = "focus" | "online";
18
19const connectionTimeFormatter = new Intl.DateTimeFormat(undefined, {
20 day: "numeric",
21 hour: "numeric",
22 minute: "2-digit",
23 month: "short",
24 second: "2-digit",
25});
26
27function formatConnectionMoment(isoDate: string | null): string | null {
28 if (!isoDate) {
29 return null;
30 }
31
32 return connectionTimeFormatter.format(new Date(isoDate));
33}
34
35function 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
40function describeOfflineToast(): string {
41 return "WebSocket disconnected. Waiting for network.";
42}
43
44function 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
52function describeExhaustedToast(): string {
53 return "Retries exhausted trying to reconnect";
54}
55
56function getConnectionDisplayName(status: WsConnectionStatus): string {
57 return status.connectionLabel?.trim() || "T3 Server";
58}
59
60function buildReconnectTitle(status: WsConnectionStatus): string {
61 return `Disconnected from ${getConnectionDisplayName(status)}`;
62}
63
64function buildRecoveredTitle(status: WsConnectionStatus): string {
65 return `Reconnected to ${getConnectionDisplayName(status)}`;
66}
67
68function 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
86function 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
93function 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
114export 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
136export 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
148export 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
381export 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
425export 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 @@
1import type { AuthSessionState } from "@t3tools/contracts";1import type { AuthSessionState } from "@t3tools/contracts";
2import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2import React, { startTransition, useEffect, useRef, useState, useCallback } from "react";3import React, { startTransition, useEffect, useRef, useState, useCallback } from "react";
34
4import { APP_DISPLAY_NAME } from "../../branding";5import { APP_DISPLAY_NAME } from "../../branding";
5import { addSavedEnvironment } from "../../environments/runtime";6import { connectPairing } from "../../connection/onboarding";
6import {7import {
7 peekPairingTokenFromUrl,8 peekPairingTokenFromUrl,
8 stripPairingTokenFromUrl,9 stripPairingTokenFromUrl,
...@@ -11,6 +12,7 @@ import {...@@ -11,6 +12,7 @@ import {
11import { readHostedPairingRequest } from "../../hostedPairing";12import { readHostedPairingRequest } from "../../hostedPairing";
12import { Button } from "../ui/button";13import { Button } from "../ui/button";
13import { Input } from "../ui/input";14import { Input } from "../ui/input";
15import { useAtomCommand } from "../../state/use-atom-command";
1416
15export function PairingPendingSurface() {17export function PairingPendingSurface() {
16 return (18 return (
...@@ -162,6 +164,9 @@ export function PairingRouteSurface({...@@ -162,6 +164,9 @@ export function PairingRouteSurface({
162}164}
163165
164export function HostedPairingRouteSurface() {166export 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;
199204
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]);
217222
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";
21import {
22 connectionStatusText,
23 type EnvironmentConnectionPresentation,
24} from "@t3tools/client-runtime/connection";
21import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";25import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
22import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";26import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
23import {27import {
...@@ -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;
448452
449 // Pending approvals / inputs453 // 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 && activeProposedPlan2423 : 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 : environmentUnavailable2425 : 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";
8import { scopeThreadRef } from "@t3tools/client-runtime";8import { scopeThreadRef } from "@t3tools/client-runtime/environment";
9import { memo } from "react";9import { memo } from "react";
10import GitActionsControl from "../GitActionsControl";10import GitActionsControl from "../GitActionsControl";
11import { type DraftId } from "~/composerDraftStore";11import { type DraftId } from "~/composerDraftStore";
12import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";12import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
13import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl";13import ProjectScriptsControl, {
14 type NewProjectScriptInput,
15 type ProjectScriptActionResult,
16} from "../ProjectScriptsControl";
14import { SidebarTrigger } from "../ui/sidebar";17import { SidebarTrigger } from "../ui/sidebar";
15import { OpenInPicker } from "./OpenInPicker";18import { OpenInPicker } from "./OpenInPicker";
16import { usePrimaryEnvironmentId } from "../../environments/primary/context";19import { usePrimaryEnvironmentId } from "../../state/environments";
17import { cn } from "~/lib/utils";20import { cn } from "~/lib/utils";
1821
19interface ChatHeaderProps {22interface 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}
3743
38export function shouldShowOpenInPicker(input: {44export 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 <OpenInPicker117 <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 @@
1import {
2 DEFAULT_MODEL,
3 DEFAULT_MODEL_BY_PROVIDER,
4 EnvironmentId,
5 ModelSelection,
6 ProviderInstanceId,
7 ProviderDriverKind,
8 ThreadId,
9} from "@t3tools/contracts";
10import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
11import "../../index.css";
12
13import { page } from "vite-plus/test/browser";
14import { afterEach, describe, expect, it, vi } from "vite-plus/test";
15import { render } from "vitest-browser-react";
16import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model";
17
18import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
19import { TraitsMenuContent } from "./TraitsPicker";
20import { createEmptyThreadDraft, useComposerDraftStore } from "../../composerDraftStore";
21
22const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
23
24function 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
44function booleanDescriptor(id: string, label: string) {
45 return {
46 id,
47 label,
48 type: "boolean" as const,
49 };
50}
51
52async 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
171describe("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}
4141
42export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) {42export 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);
4545 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]);
5149
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}
1313
14export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({14export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({
apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx deleted-41
...@@ -1,41 +0,0 @@
1import "../../index.css";
2
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4import { page } from "vite-plus/test/browser";
5import { render } from "vitest-browser-react";
6
7import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments";
8
9describe("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}
1010
11export const ExpandedImageDialog = memo(function ExpandedImageDialog({11export 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);
1616 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]);
2117
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 }, []);
3121
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]);
5545
56 const item = preview.images[preview.index];46 const item = preview.images[index];
57 if (!item) return null;47 if (!item) return null;
5848
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 @@
1import "../../index.css";
2
3import { EnvironmentId } from "@t3tools/contracts";
4import { createRef } from "react";
5import type { LegendListRef } from "@legendapp/list/react";
6import { page } from "vite-plus/test/browser";
7import { afterEach, describe, expect, it, vi } from "vite-plus/test";
8import { render } from "vitest-browser-react";
9
10const scrollToEndSpy = vi.fn();
11const getStateSpy = vi.fn(() => ({ isAtEnd: true }));
12
13vi.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
47import { MessagesTimeline } from "./MessagesTimeline";
48
49const MESSAGE_CREATED_AT = "2026-04-13T12:00:00.000Z";
50
51function 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
74function 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
80function 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
95function 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
110describe("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", () => {
2223
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 ]);
3341
...@@ -39,20 +47,28 @@ describe("computeMessageDurationStart", () => {...@@ -39,20 +47,28 @@ describe("computeMessageDurationStart", () => {
39 );47 );
40 });48 });
4149
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 ]);
5874
...@@ -65,15 +81,28 @@ describe("computeMessageDurationStart", () => {...@@ -65,15 +81,28 @@ describe("computeMessageDurationStart", () => {
65 );81 );
66 });82 });
6783
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 ]);
79108
...@@ -88,19 +117,33 @@ describe("computeMessageDurationStart", () => {...@@ -88,19 +117,33 @@ describe("computeMessageDurationStart", () => {
88117
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 ]);
106149
...@@ -116,13 +159,26 @@ describe("computeMessageDurationStart", () => {...@@ -116,13 +159,26 @@ describe("computeMessageDurationStart", () => {
116159
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 ]);
128184
...@@ -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 };
331390
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 the545 // A steer ends the previous turn early: its only message completes the
485 // instant it is created, and trailing work entries land after it. The546 // 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 to547 // 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 };
837904
...@@ -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 };
9401009
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}
3132
32export type TimelineLatestTurn = Pick<33export 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 }
9293
...@@ -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 its257 // 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";
8import { parseScopedThreadKey } from "@t3tools/client-runtime";8import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
9import {9import {
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 <TooltipTrigger607 <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();
117117
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 @@
1import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts";1import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useMemo } from "react";2import { memo, useCallback, useEffect, useMemo } from "react";
3import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings";3import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings";
4import { usePreferredEditor } from "../../editorPreferences";4import { usePreferredEditor } from "../../editorPreferences";
...@@ -32,7 +32,8 @@ import {...@@ -32,7 +32,8 @@ import {
32 WebStormIcon,32 WebStormIcon,
33} from "../JetBrainsIcons";33} from "../JetBrainsIcons";
34import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";34import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";
35import { readLocalApi } from "~/localApi";35import { shellEnvironment } from "~/state/shell";
36import { useAtomCommand } from "~/state/use-atom-command";
3637
37const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => {38const 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};
152153
153export const OpenInPicker = memo(function OpenInPicker({154export 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({
172176
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 );
184194
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;
197206
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 ]);
204226
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 @@
1import { memo, useState, useId } from "react";1import { memo, useState, useId } from "react";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";6import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
3import {7import {
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";
27import { stackedThreadToast, toastManager } from "../ui/toast";31import { stackedThreadToast, toastManager } from "../ui/toast";
28import { readEnvironmentApi } from "~/environmentApi";32import { projectEnvironment } from "~/state/projects";
29import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";33import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
34import { useAtomCommand } from "~/state/use-atom-command";
3035
31export const ProposedPlanCard = memo(function ProposedPlanCard({36export 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 };
92100
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 }
106113
107 setIsSavingToWorkspace(true);114 setIsSavingToWorkspace(true);
108 void api.projects115 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 };
140146
141 return (147 return (
apps/web/src/components/chat/ProviderModelPicker.browser.tsx deleted-1316
...@@ -1,1316 +0,0 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { EnvironmentId } from "@t3tools/contracts";
3import { createModelCapabilities } from "@t3tools/shared/model";
4import { page, userEvent } from "vite-plus/test/browser";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6import { render } from "vitest-browser-react";
7
8import { ProviderModelPicker } from "./ProviderModelPicker";
9import { getCustomModelOptionsByInstance } from "../../modelSelection";
10import {
11 deriveProviderInstanceEntries,
12 sortProviderInstanceEntries,
13} from "../../providerInstances";
14import type { ModelEsque } from "./providerIconUtils";
15import {
16 DEFAULT_CLIENT_SETTINGS,
17 DEFAULT_UNIFIED_SETTINGS,
18 type UnifiedSettings,
19} from "@t3tools/contracts/settings";
20import { __resetLocalApiForTests } from "../../localApi";
21
22// Mock the environments/runtime module to provide a mock primary environment connection
23vi.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
79function 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
95function booleanDescriptor(id: string, label: string) {
96 return {
97 id,
98 label,
99 type: "boolean" as const,
100 };
101}
102
103const 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
213const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex");
214const CLAUDE_INSTANCE_ID = ProviderInstanceId.make("claudeAgent");
215const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");
216
217function 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
234function 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
250async 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
303function getModelPickerListElement() {
304 const modelPickerList = document.querySelector<HTMLElement>(".model-picker-list");
305 expect(modelPickerList).not.toBeNull();
306 return modelPickerList!;
307}
308
309function getModelPickerListText() {
310 return getModelPickerListElement().textContent ?? "";
311}
312
313function 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
319function getSidebarProviderOrder() {
320 return Array.from(document.querySelectorAll<HTMLElement>("[data-model-picker-provider]")).map(
321 (element) => element.dataset.modelPickerProvider ?? "",
322 );
323}
324
325describe("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";
20import { setModelPickerOpen } from "../../modelPickerOpenState";
21import type { ProviderInstanceEntry } from "../../providerInstances";20import type { ProviderInstanceEntry } from "../../providerInstances";
2221
23export const ProviderModelPicker = memo(function ProviderModelPicker(props: {22export 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 };
8180
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 @@
1import "../../index.css";
2
3import { page, userEvent } from "vite-plus/test/browser";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5import { render } from "vitest-browser-react";
6
7import type { DesktopCloudAuthOAuthOption } from "../../cloud/desktopAuth";
8import { DesktopClerkSignInCard } from "./DesktopClerkSignIn";
9
10const GOOGLE: DesktopCloudAuthOAuthOption = {
11 strategy: "oauth_google",
12 label: "Google",
13 providerId: "google",
14 iconUrl: null,
15};
16
17const 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
33describe("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 @@
1import "../../index.css";
2
3import { page } from "vite-plus/test/browser";
4import { beforeEach, describe, expect, it } from "vite-plus/test";
5import { render } from "vitest-browser-react";
6
7import {
8 finishRelayClientInstall,
9 readRelayClientInstallDialogState,
10 reportRelayClientInstallProgress,
11 requestRelayClientInstallConfirmation,
12 resetRelayClientInstallDialogForTests,
13} from "../../cloud/relayClientInstallDialog";
14import { RelayClientInstallDialog } from "./RelayClientInstallDialog";
15
16describe("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 {
3030
31export function SshPasswordPromptDialog() {31export 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();
4134
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 }, []);
5245
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
63function 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();
5977
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 }, []);
6987
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 }, []);
8296
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);
89103 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 };
103106
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 }
108111
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 };
132135
133 const dismissExpiredPrompt = () => {136 const dismissExpiredPrompt = () => {
134 if (currentRequest) {137 onRemove(request.requestId);
135 removeCurrentPrompt(currentRequest.requestId);
136 }
137 };138 };
138139
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 };
146147
147 const target = currentRequest ? describeSshTarget(currentRequest) : null;148 const target = describeSshTarget(request);
148149
149 return (150 return (
150 <Dialog151 <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 the164 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 <span180 <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 @@
1import "../../index.css";
2
3import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
4import { scopeThreadRef } from "@t3tools/client-runtime";
5import { EnvironmentId, ThreadId } from "@t3tools/contracts";
6import { afterEach, describe, expect, it, vi } from "vite-plus/test";
7import { page } from "vite-plus/test/browser";
8import { render } from "vitest-browser-react";
9
10import { useComposerDraftStore } from "~/composerDraftStore";
11
12import { AnnotatableFileDiff } from "./AnnotatableFileDiff";
13
14function 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
26const threadRef = scopeThreadRef(EnvironmentId.make("local"), ThreadId.make("thread-1"));
27
28function 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
60async 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
68describe("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 @@
1import "../../index.css";
2
3import type { LineAnnotation, SelectedLineRange } from "@pierre/diffs";
4import { Editor } from "@pierre/diffs/editor";
5import { EditorProvider, File } from "@pierre/diffs/react";
6import { afterEach, describe, expect, it, vi } from "vite-plus/test";
7import { page } from "vite-plus/test/browser";
8import { render } from "vitest-browser-react";
9import { useEffect, useMemo, useRef, useState } from "react";
10
11import { installFileEditorDismissal } from "./fileEditorDismissal";
12
13interface AnnotationMetadata {
14 label: string;
15}
16
17function 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
29function 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
88async 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
102describe("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 {
7import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";7import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";
8import { Editor } from "@pierre/diffs/editor";8import { Editor } from "@pierre/diffs/editor";
9import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";9import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";
10import {
11 isAtomCommandInterrupted,
12 squashAtomCommandFailure,
13} from "@t3tools/client-runtime/state/runtime";
10import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";14import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
11import { useCallback, useEffect, useMemo, useRef, useState } from "react";15import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1216
13import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";17import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
14import ChatMarkdown from "~/components/ChatMarkdown";18import ChatMarkdown from "~/components/ChatMarkdown";
15import { OpenInPicker } from "~/components/chat/OpenInPicker";19import { OpenInPicker } from "~/components/chat/OpenInPicker";
16import { ensureEnvironmentApi } from "~/environmentApi";
17import { usePrimaryEnvironmentId } from "~/environments/primary/context";
18import { useTheme } from "~/hooks/useTheme";20import { useTheme } from "~/hooks/useTheme";
19import { resolveDiffThemeName } from "~/lib/diffRendering";21import { resolveDiffThemeName } from "~/lib/diffRendering";
20import { cn } from "~/lib/utils";22import { 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";
26import { stackedThreadToast, toastManager } from "~/components/ui/toast";28import { stackedThreadToast, toastManager } from "~/components/ui/toast";
27import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";29import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
28import { buildFileReviewComment } from "~/reviewCommentContext";30import { buildFileReviewComment } from "~/reviewCommentContext";
31import { assetEnvironment } from "~/state/assets";
32import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments";
33import { previewEnvironment } from "~/state/preview";
34import { projectEnvironment } from "~/state/projects";
35import { useAtomCommand } from "~/state/use-atom-command";
36import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
2937
30import FileBrowserPanel from "./FileBrowserPanel";38import FileBrowserPanel from "./FileBrowserPanel";
31import {39import {
...@@ -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 );
277284
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 };
644658
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]);
657682
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 <OpenInPicker720 <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 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
3import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/unstable/reactivity";
25
3import { FileSaveCoordinator } from "./fileSaveCoordinator";6import { FileSaveCoordinator } from "./fileSaveCoordinator";
47
5function deferred() {8function 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", () => {
1720
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 = vi51 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);
6368
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 @@
1export interface FileSaveCoordinatorOptions {1import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
2
3export 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}
79
8export class FileSaveCoordinator {10export 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;
1517
16 constructor(private readonly options: FileSaveCoordinatorOptions) {}18 constructor(private readonly options: FileSaveCoordinatorOptions<A, E>) {}
1719
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 }
5859
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 @@
1import type {1import type { ProjectReadFileResult } from "@t3tools/contracts";
2 EnvironmentApi,
3 ProjectListEntriesResult,
4 ProjectReadFileResult,
5} from "@t3tools/contracts";
6import { EnvironmentId } from "@t3tools/contracts";2import { EnvironmentId } from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity";
9import { afterEach, describe, expect, it, vi } from "vite-plus/test";3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
104
11import {5import {
12 __resetEnvironmentApiOverridesForTests,6 clearProjectFileQueryData,
13 __setEnvironmentApiOverrideForTests,
14} from "~/environmentApi";
15import { appAtomRegistry } from "~/rpc/atomRegistry";
16
17import {
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 {
2612
27const environmentId = EnvironmentId.make("environment-project-files-query-test");13const environmentId = EnvironmentId.make("environment-project-files-query-test");
2814
29function 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
37describe("project files queries", () => {15describe("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 });
4320
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"}');
10731
...@@ -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);
11539
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 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
2import type {2import type {
3 EnvironmentId,3 EnvironmentId,
4 ProjectListEntriesResult,4 ProjectListEntriesResult,
5 ProjectReadFileResult,5 ProjectReadFileResult,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";7import * as Cause from "effect/Cause";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";8import * as Option from "effect/Option";
11import { AsyncResult, Atom } from "effect/unstable/reactivity";9import { AsyncResult } from "effect/unstable/reactivity";
12import { useCallback, useEffect } from "react";10import { useCallback } from "react";
1311
14import { ensureEnvironmentApi } from "~/environmentApi";
15import { appAtomRegistry } from "~/rpc/atomRegistry";12import { appAtomRegistry } from "~/rpc/atomRegistry";
13import { projectEnvironment } from "~/state/projects";
14import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
1615
17const PROJECT_QUERY_STALE_TIME_MS = 30_000;
18const PROJECT_QUERY_IDLE_TTL_MS = 5 * 60_000;
19const EMPTY_PROJECT_FILE_PATH = "";16const EMPTY_PROJECT_FILE_PATH = "";
20interface OptimisticProjectFile {17function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) {
21 readonly data: ProjectReadFileResult;18 return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath });
22 readonly confirmed: boolean;
23}19}
2420
25const optimisticProjectFiles = new Map<string, OptimisticProjectFile>();
26
27class ProjectQueryError extends Data.TaggedError("ProjectQueryError")<{
28 readonly message: string;
29 readonly cause?: unknown;
30}> {}
31
32function queryError(message: string, cause: unknown): ProjectQueryError {
33 return new ProjectQueryError({ message, cause });
34}
35
36function entriesKey(environmentId: EnvironmentId, cwd: string): string {
37 return [environmentId, cwd].map(encodeURIComponent).join("|");
38}
39
40function fileKey(environmentId: EnvironmentId, cwd: string, relativePath: string): string {
41 return [environmentId, cwd, relativePath].map(encodeURIComponent).join("|");
42}
43
44function keyParts(key: string): string[] {
45 return key.split("|").map(decodeURIComponent);
46}
47
48const 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
67const 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
87interface ProjectQueryState<A> {21interface 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}
9327
94export function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) {28export function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) {
95 return projectEntriesQueryAtom(entriesKey(environmentId, cwd));29 return projectEnvironment.listEntries({ environmentId, input: { cwd } });
96}30}
9731
98export function getProjectFileQueryAtom(32export 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}
10542
106export function setProjectFileQueryData(43export 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}
13167
132export function confirmProjectFileQueryData(68export 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;
14177
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}
14695
...@@ -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}
156105
157export function __resetProjectFileQueryDataForTests(): void {106export 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}
160113
161function errorMessage<A>(result: AsyncResult.AsyncResult<A, unknown>): string | null {114function 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 === null147 optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH),
193 ? undefined148 );
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]);
207150
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";
22
3import type { DesktopPreviewPointerEvent } from "@t3tools/contracts";
3import { MousePointer2 } from "lucide-react";4import { MousePointer2 } from "lucide-react";
4import { useEffect, useState } from "react";5import { useEffect, useState } from "react";
56
...@@ -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
33function 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);
2040
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;
2945
30 return (46 return (
31 <div47 <div
apps/web/src/components/preview/PreviewAutomationOwner.test.ts created+31
...@@ -0,0 +1,31 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { observeAutomationOwnerConnectedGeneration } from "./PreviewAutomationOwner";
4
5describe("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";
22
3import { scopedThreadKey } from "@t3tools/client-runtime";3import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
4import type {4import 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";
12import { useCallback, useEffect, useId, useRef } from "react";12import { useCallback, useEffect, useId, useRef } from "react";
1313
14import { ensureEnvironmentApi } from "~/environmentApi";14import {
15import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";15 applyPreviewServerSnapshot,
16 readThreadPreviewState,
17 subscribeThreadPreviewState,
18} from "~/previewStateStore";
16import { useRightPanelStore } from "~/rightPanelStore";19import { useRightPanelStore } from "~/rightPanelStore";
17import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";20import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
18import {21import { startBrowserRecording, stopBrowserRecording } from "~/browser/browserRecording";
19 startBrowserRecording,22import { previewEnvironment } from "~/state/preview";
20 stopBrowserRecording,23import { useEnvironmentQuery } from "~/state/query";
21 useBrowserRecordingStore,24import { useEnvironmentConnectionState } from "~/state/environments";
22} from "~/browser/browserRecording";25import { useAtomCommand } from "~/state/use-atom-command";
2326
24import { previewBridge } from "./previewBridge";27import { previewBridge } from "./previewBridge";
2528
29export 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
26const waitForDesktopOverlay = async (48const 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]);
236286
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]);
277311
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]);
309368
310 return null;369 return null;
311}370}
apps/web/src/components/preview/PreviewChromeRow.browser.tsx deleted-85
...@@ -1,85 +0,0 @@
1import "../../index.css";
2
3import { page } from "vite-plus/test/browser";
4import { describe, expect, it, vi } from "vite-plus/test";
5import { render } from "vitest-browser-react";
6
7import { PreviewChromeRow } from "./PreviewChromeRow";
8
9const 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
22describe("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);
8989
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 <InputGroupInput166 <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";
22
3import { scopedThreadKey } from "@t3tools/client-runtime";3import { scopedThreadKey } from "@t3tools/client-runtime/environment";
4import { type ScopedThreadRef } from "@t3tools/contracts";4import { type ScopedThreadRef } from "@t3tools/contracts";
5import { useCallback, useEffect, useRef, useState } from "react";5import { useCallback, useEffect, useRef, useState } from "react";
66
7import { useComposerDraftStore } from "~/composerDraftStore";7import { useComposerDraftStore } from "~/composerDraftStore";
8import { ensureEnvironmentApi } from "~/environmentApi";
9import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";8import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";
10import { ensureLocalApi } from "~/localApi";9import { ensureLocalApi } from "~/localApi";
11import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";10import { rememberPreviewUrl, useThreadPreviewState } from "~/previewStateStore";
12import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";11import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
13import { readEnvironmentConnection } from "~/environments/runtime";12import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments";
13import { previewEnvironment } from "~/state/preview";
14import { useAtomCommand } from "~/state/use-atom-command";
1415
15import { previewBridge } from "./previewBridge";16import { previewBridge } from "./previewBridge";
16import { subscribePreviewAction } from "./previewActionBus";17import { subscribePreviewAction } from "./previewActionBus";
...@@ -30,7 +31,7 @@ import { AgentBrowserCursor } from "./AgentBrowserCursor";...@@ -30,7 +31,7 @@ import { AgentBrowserCursor } from "./AgentBrowserCursor";
30import {31import {
31 startBrowserRecording,32 startBrowserRecording,
32 stopBrowserRecording,33 stopBrowserRecording,
33 useBrowserRecordingStore,34 useActiveBrowserRecordingTabId,
34} from "~/browser/browserRecording";35} from "~/browser/browserRecording";
35import { stackedThreadToast, toastManager } from "~/components/ui/toast";36import { stackedThreadToast, toastManager } from "~/components/ui/toast";
3637
...@@ -50,16 +51,15 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi();...@@ -50,16 +51,15 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi();
50export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) {51export 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);
6363
64 usePreviewSession(threadRef);64 usePreviewSession(threadRef);
6565
...@@ -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 && environmentConnection87 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;
9594
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 the100 // 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 );
121117
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 @@
1import type { DiscoveredLocalServer, ScopedThreadRef } from "@t3tools/contracts";1import type { DiscoveredLocalServer, ScopedThreadRef } from "@t3tools/contracts";
2import {
3 mapAtomCommandResult,
4 type AtomCommandResult,
5} from "@t3tools/client-runtime/state/runtime";
26
3import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";7import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
4import { ensureEnvironmentApi } from "~/environmentApi";8import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
5import { usePreviewStateStore } from "~/previewStateStore";
6import { useRightPanelStore } from "~/rightPanelStore";9import { useRightPanelStore } from "~/rightPanelStore";
7import { openPreviewSession } from "./openPreviewSession";10import { openPreviewSession } from "./openPreviewSession";
811
9export async function openDiscoveredPort(input: {12export 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 @@
1import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";1import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/unstable/reactivity";
4import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore";
37
4import { openPreviewSession } from "./openPreviewSession";8import { openPreviewSession } from "./openPreviewSession";
59
...@@ -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};
2327
28beforeEach(resetPreviewStateForTests);
29
24describe("openPreviewSession", () => {30describe("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();
2933
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 });
3739
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 @@
1import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";1import type {
2 EnvironmentId,
3 PreviewOpenInput,
4 PreviewSessionSnapshot,
5 ScopedThreadRef,
6} from "@t3tools/contracts";
7import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
28
3import type { PreviewStateStoreState } from "~/previewStateStore";9import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
410
5interface OpenPreviewSessionInput {11interface 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}
1219
13export async function openPreviewSession(20export 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 @@
1import type { EnvironmentApi, LocalApi, ScopedThreadRef } from "@t3tools/contracts";1import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts";
2import { isPreviewableUrl } from "@t3tools/shared/preview";2import { isPreviewableUrl } from "@t3tools/shared/preview";
33
4import { isPreviewSupportedInRuntime } from "~/previewStateStore";4import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
5import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";
5import { useRightPanelStore } from "~/rightPanelStore";6import { useRightPanelStore } from "~/rightPanelStore";
67
7interface OpenTerminalLinkInPreviewInput {8interface 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}
1616
17/**17export 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 */
27export 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 }
5343
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 }
6657
apps/web/src/components/preview/previewSessionState.ts deleted-77
...@@ -1,77 +0,0 @@
1import { useAtomValue } from "@effect/atom-react";
2import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime";
3import type { PreviewListResult, ScopedThreadRef } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import { AsyncResult, Atom } from "effect/unstable/reactivity";
9
10import { ensureEnvironmentApi } from "~/environmentApi";
11import { readPreviewStateRevision } from "~/previewStateStore";
12import { appAtomRegistry } from "~/rpc/atomRegistry";
13
14const PREVIEW_SESSION_STALE_TIME_MS = 5_000;
15const PREVIEW_SESSION_IDLE_TTL_MS = 5 * 60_000;
16
17class PreviewSessionQueryError extends Data.TaggedError("PreviewSessionQueryError")<{
18 readonly message: string;
19 readonly cause?: unknown;
20}> {}
21
22const 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
52export 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
61export function refreshPreviewSessionState(threadRef: ScopedThreadRef): void {
62 appAtomRegistry.refresh(previewSessionListAtom(scopedThreadKey(threadRef)));
63}
64
65export 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 {
9import { useEffect, useRef } from "react";9import { useEffect, useRef } from "react";
1010
11import { useBrowserPointerStore } from "~/browser/browserPointerStore";11import { useBrowserPointerStore } from "~/browser/browserPointerStore";
12import { ensureEnvironmentApi } from "~/environmentApi";12import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore";
13import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore";13import { previewEnvironment } from "~/state/preview";
14import { useAtomCommand } from "~/state/use-atom-command";
1415
15import { previewBridge } from "./previewBridge";16import { previewBridge } from "./previewBridge";
1617
...@@ -20,8 +21,8 @@ import { previewBridge } from "./previewBridge";...@@ -20,8 +21,8 @@ import { previewBridge } from "./previewBridge";
20 */21 */
21export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void {22export 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;
2627
27 // One bridge subscription does both jobs (mirror state + forward to28 // 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}
6063
61function shouldClearBrowserPointer(64function shouldClearBrowserPointer(
apps/web/src/components/preview/usePreviewSession.ts+95-94
...@@ -1,110 +1,111 @@...@@ -1,110 +1,111 @@
1"use client";1"use client";
22
3import { scopedThreadKey } from "@t3tools/client-runtime";3import { useAtomValue } from "@effect/atom-react";
4import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment";
5import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
4import type { ScopedThreadRef } from "@t3tools/contracts";6import type { ScopedThreadRef } from "@t3tools/contracts";
5import { useEffect } from "react";7import { AsyncResult, Atom } from "effect/unstable/reactivity";
68
7import { ensureEnvironmentApi, readEnvironmentApi } from "~/environmentApi";9import {
8import { readEnvironmentConnection, subscribeEnvironmentConnections } from "~/environments/runtime";10 applyPreviewServerEvent,
9import { readPreviewStateRevision, usePreviewStateStore } from "~/previewStateStore";11 applyPreviewServerSnapshot,
12 readThreadPreviewState,
13} from "~/previewStateStore";
14import { previewEnvironment } from "~/state/preview";
1015
11import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState";16const previewSessionSyncAtom = Atom.family((threadKey: string) => {
17 const threadRef = parseScopedThreadKey(threadKey);
18 if (!threadRef) {
19 throw new Error(`Invalid scoped preview thread key: ${threadKey}`);
20 }
1221
13/**22 const sessionsAtom = previewEnvironment.list({
14 * Subscribes to the server's per-thread preview events and replays the23 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 the26 const eventsAtom = previewEnvironment.events({
18 * server has none (server restarted while we were alive), re-issue27 environmentId: threadRef.environmentId,
19 * `preview.open` so subsequent events land on a real session.28 input: {},
20 */29 });
21export 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]);
7130
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;
7637
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 }
8248
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;
8759
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 };
10278
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
109export 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 @@
22
3import { CheckIcon } from "lucide-react";3import { CheckIcon } from "lucide-react";
4import { Radio as RadioPrimitive } from "@base-ui/react/radio";4import { Radio as RadioPrimitive } from "@base-ui/react/radio";
5import { useCallback, useEffect, useMemo, useState } from "react";5import { useCallback, useMemo, useState } from "react";
6import {6import {
7 ProviderInstanceId,7 ProviderInstanceId,
8 ProviderDriverKind,8 ProviderDriverKind,
...@@ -115,14 +115,13 @@ interface AddProviderInstanceDialogProps {...@@ -115,14 +115,13 @@ interface AddProviderInstanceDialogProps {
115115
116export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) {116export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) {
117 const settings = useSettings();117 const settings = useSettings();
118 const { updateSettings } = useUpdateSettings();118 const updateSettings = useUpdateSettings();
119119
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 drivers125 // 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 );
137136
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";
32import { WsRpcClient } from "@t3tools/client-runtime";32import {
33 connectionStatusText,
34 RelayConnectionRegistration,
35 RelayConnectionTarget,
36} from "@t3tools/client-runtime/connection";
37import { findErrorTraceId } from "@t3tools/client-runtime/errors";
38import {
39 isAtomCommandInterrupted,
40 settlePromise,
41 squashAtomCommandFailure,
42} from "@t3tools/client-runtime/state/runtime";
33import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";43import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
34import * as DateTime from "effect/DateTime";44import * as DateTime from "effect/DateTime";
45import * as Option from "effect/Option";
3546
36import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";47import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
37import { cn } from "../../lib/utils";48import { cn } from "../../lib/utils";
...@@ -76,7 +87,6 @@ import { stackedThreadToast, toastManager } from "../ui/toast";...@@ -76,7 +87,6 @@ import { stackedThreadToast, toastManager } from "../ui/toast";
76import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";87import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
77import { Button } from "../ui/button";88import { Button } from "../ui/button";
78import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";89import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
79import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";
80import { Group, GroupSeparator } from "../ui/group";90import { Group, GroupSeparator } from "../ui/group";
81import { AnimatedHeight } from "../AnimatedHeight";91import { AnimatedHeight } from "../AnimatedHeight";
82import {92import {
...@@ -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";
105import {
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";
118import { useUiStateStore } from "~/uiStateStore";114import { useUiStateStore } from "~/uiStateStore";
119import { resolveServerConfigVersionMismatch } from "~/versionSkew";115import { resolveServerConfigVersionMismatch } from "~/versionSkew";
120import { useServerConfig } from "~/rpc/serverState";
121import {
122 connectManagedCloudEnvironment,
123 linkPrimaryEnvironmentToCloud,
124 unlinkPrimaryEnvironmentFromCloud,
125 updatePrimaryCloudPreferences,
126} from "~/cloud/linkEnvironment";
127import {
128 refreshManagedRelayEnvironments,
129 useManagedRelayEnvironments,
130} from "~/cloud/managedRelayState";
131import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";116import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";
132import { webRuntime } from "~/lib/runtime";
133import { hasCloudPublicConfig } from "~/cloud/publicConfig";117import { hasCloudPublicConfig } from "~/cloud/publicConfig";
118import {
119 linkPrimaryEnvironment as linkPrimaryEnvironmentAtom,
120 unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom,
121} from "~/cloud/linkEnvironmentAtoms";
122import { authEnvironment } from "~/state/auth";
123import { environmentCatalog } from "~/connection/catalog";
124import {
125 connectPairing as connectPairingAtom,
126 connectSshEnvironment as connectSshEnvironmentAtom,
127} from "~/connection/onboarding";
128import { useEnvironmentQuery } from "~/state/query";
129import {
130 desktopNetworkAccessStateAtom,
131 refreshDesktopNetworkAccessState,
132} from "~/state/desktopNetworkAccess";
133import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts";
134import {
135 type EnvironmentPresentation,
136 useEnvironments,
137 usePrimaryEnvironment,
138 useRelayEnvironmentDiscovery,
139} from "~/state/environments";
140import { relayEnvironmentDiscovery } from "~/state/relay";
141import { useAtomCommand } from "../../state/use-atom-command";
134142
135const DEFAULT_TAILSCALE_SERVE_PORT = 443;143const DEFAULT_TAILSCALE_SERVE_PORT = 443;
144const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = [];
145const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray<DesktopDiscoveredSshHost> = [];
136146
137const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {147const 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 <button285 <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}
293304
294function getSavedBackendStatusTooltip(305function 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
319function 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}
503489
504function 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
517function removeDesktopPairingLink(current: ReadonlyArray<ServerPairingLinkRecord>, id: string) {
518 return current.filter((pairingLink) => pairingLink.id !== id);
519}
520
521function 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
536function removeDesktopClientSession(
537 current: ReadonlyArray<ServerClientSessionRecord>,
538 sessionId: ServerClientSessionRecord["sessionId"],
539) {
540 return current.filter((clientSession) => clientSession.sessionId !== sessionId);
541}
542
543function selectPairingEndpoint(490function 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 <TooltipTrigger831 {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 <TooltipTrigger848 size="xs"
907 render={849 variant="outline"
908 <Button850 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 <MenuTrigger860 <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 <TooltipTrigger1303 className="min-w-0 truncate text-xs leading-5 text-muted-foreground"
1371 render={1304 title={endpoint.httpBaseUrl}
1372 <p1305 >
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}
14721398
1473type SavedBackendListRowProps = {1399type 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};
14821405
1483function SavedBackendListRow({1406function 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.lastConnectedAt1435 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);
15231437
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" : null1447 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 <Button1480 <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 {isConnected1486 {isConnected
1565 ? isDisconnecting1487 ? removingEnvironmentId === environmentId
1566 ? "Disconnecting…"1488 ? "Disconnecting…"
1567 : "Disconnect"1489 : "Disconnect"
1568 : isConnecting1490 : 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 <Switch1552 <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({
16541568
1655function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {1569function 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 };
16621603
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 = !isSignedIn1665 const disabledReason = !isSignedIn
1724 ? "Sign in to T3 Connect"1666 ? "Sign in from T3 Cloud settings to manage this environment."
1725 : !canManageRelay1667 : !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;
17291671
1730 return (1672 return (
1731 <>1673 <SettingsRow
1732 <SettingsRow1674 title="T3 Cloud"
1733 title="T3 Connect"1675 description={
1734 description={1676 linked
1735 linked1677 ? "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 <CloudLinkSwitch1683 checked={linked}
1742 checked={linked}1684 disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating}
1743 disabled={1685 disabledReason={disabledReason}
1744 (isSignedIn && !canManageRelay) || primaryCloudLinkState.isPending || isUpdating1686 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}
17811692
...@@ -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}
17851696
1786function EmptyRemoteEnvironments({1697function 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 environment1706 {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]);
18531759
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 };
18811798
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 );
18871804
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 }
18911812
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 }
19001816
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 <ConnectionStatusDot1822 <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 <Button1860 <Button
1915 size="sm"1861 size="sm"
...@@ -1942,7 +1888,15 @@ function CloudRemoteEnvironmentRows({...@@ -1942,7 +1888,15 @@ function CloudRemoteEnvironmentRows({
19421888
1943export function ConnectionsSettings() {1889export 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 = desktopBridge1901 const currentSessionScopes = desktopBridge
1948 ? AuthAdministrativeScopes1902 ? 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);
19911955
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"] | null1990 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 = desktopBridge2062 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() {
21932189
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() {
22362232
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 }
22932283
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 ]);
23372361
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 }, []);
23772381
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);
23972390 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 }),
24052398 );
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]);
24202403
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 );
24582443
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 <TooltipTrigger3002 className="mt-1 truncate text-sm text-foreground"
3147 render={3003 title={pendingTailscaleServeBaseUrl ?? undefined}
3148 <p3004 >
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 <SavedBackendListRow3108 <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";
10import { useAtomValue } from "@effect/atom-react";
11import {
12 isAtomCommandInterrupted,
13 squashAtomCommandFailure,
14} from "@t3tools/client-runtime/state/runtime";
10import { useCallback, useMemo, useState, type ReactNode } from "react";15import { useCallback, useMemo, useState, type ReactNode } from "react";
11import type {16import type {
12 ServerProcessDiagnosticsEntry,17 ServerProcessDiagnosticsEntry,
...@@ -16,21 +21,23 @@ import type {...@@ -16,21 +21,23 @@ import type {
16import * as DateTime from "effect/DateTime";21import * as DateTime from "effect/DateTime";
17import * as Option from "effect/Option";22import * as Option from "effect/Option";
1823
19import { ensureLocalApi } from "../../localApi";
20import { cn } from "../../lib/utils";24import { cn } from "../../lib/utils";
21import { resolveAndPersistPreferredEditor } from "../../editorPreferences";25import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
22import { formatRelativeTime } from "../../timestampFormat";26import { formatRelativeTime } from "../../timestampFormat";
23import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState";27import { useEnvironmentQuery } from "../../state/query";
24import {28import {
25 useProcessDiagnostics,29 primaryServerAvailableEditorsAtom,
26 useProcessResourceHistory,30 primaryServerObservabilityAtom,
27} from "../../lib/processDiagnosticsState";31 serverEnvironment,
28import { useTraceDiagnostics } from "../../lib/traceDiagnosticsState";32} from "../../state/server";
33import { shellEnvironment } from "../../state/shell";
34import { usePrimaryEnvironment } from "../../state/environments";
29import { Button } from "../ui/button";35import { Button } from "../ui/button";
30import { ScrollArea } from "../ui/scroll-area";36import { ScrollArea } from "../ui/scroll-area";
31import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";37import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
32import { toastManager } from "../ui/toast";38import { toastManager } from "../ui/toast";
33import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";39import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
40import { useAtomCommand } from "../../state/use-atom-command";
3441
35const NUMBER_FORMAT = new Intl.NumberFormat();42const NUMBER_FORMAT = new Intl.NumberFormat();
3643
...@@ -803,28 +810,51 @@ function DiagnosticsRefreshButton({...@@ -803,28 +810,51 @@ function DiagnosticsRefreshButton({
803}810}
804811
805export function DiagnosticsSettingsPanel() {812export 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 }
841875
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]);
855895
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 }
866909
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 );
906953
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";
30import { useAtomValue } from "@effect/atom-react";
31import {
32 isAtomCommandInterrupted,
33 squashAtomCommandFailure,
34} from "@t3tools/client-runtime/state/runtime";
3035
31import { isElectron } from "../../env";36import { isElectron } from "../../env";
32import { openInPreferredEditor } from "../../editorPreferences";37import { useOpenInPreferredEditor } from "../../editorPreferences";
33import { formatShortcutLabel } from "../../keybindings";38import { formatShortcutLabel } from "../../keybindings";
34import { cn } from "../../lib/utils";39import { cn } from "../../lib/utils";
35import { ensureLocalApi } from "../../localApi";40import {
36import { useServerKeybindings, useServerKeybindingsConfigPath } from "../../rpc/serverState";41 primaryServerAvailableEditorsAtom,
42 primaryServerKeybindingsAtom,
43 primaryServerKeybindingsConfigPathAtom,
44 serverEnvironment,
45} from "../../state/server";
46import { usePrimaryEnvironment } from "../../state/environments";
37import { Button } from "../ui/button";47import { Button } from "../ui/button";
38import { Input } from "../ui/input";48import { Input } from "../ui/input";
39import { Kbd, KbdGroup } from "../ui/kbd";49import { Kbd, KbdGroup } from "../ui/kbd";
...@@ -61,6 +71,7 @@ import {...@@ -61,6 +71,7 @@ import {
61} from "./KeybindingsSettings.logic";71} from "./KeybindingsSettings.logic";
62import { SettingsPageContainer, SettingsSection } from "./settingsLayout";72import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
63import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";73import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
74import { useAtomCommand } from "../../state/use-atom-command";
6475
65function KeybindingPill({ value }: { value: string }) {76function 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}
10701081
1071export function KeybindingsSettingsPanel() {1082export 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() {
11071130
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]);
11191147
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 );
11441180
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 );
11601203
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";
13import * as Arr from "effect/Array";13import * as Arr from "effect/Array";
14import * as Result from "effect/Result";14import * as Result from "effect/Result";
15import { useEffect, useState, type ReactNode } from "react";15import { useState, type ReactNode } from "react";
16import {16import {
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 );
163163
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 @@
1import "../../index.css";
2
3import {
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";
21import * as DateTime from "effect/DateTime";
22import * as Option from "effect/Option";
23import { page } from "vite-plus/test/browser";
24import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
25import { render } from "vitest-browser-react";
26import type { ReactNode } from "react";
27import {
28 RouterProvider,
29 createMemoryHistory,
30 createRootRoute,
31 createRoute,
32 createRouter,
33} from "@tanstack/react-router";
34
35import { __resetLocalApiForTests } from "../../localApi";
36import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry";
37import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
38import { useUiStateStore } from "../../uiStateStore";
39import { ConnectionsSettings } from "./ConnectionsSettings";
40import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings";
41import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels";
42import { SourceControlSettingsPanel } from "./SourceControlSettings";
43
44function 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
60const 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
150const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn());
151const mockGetClerkToken = vi.hoisted(() => vi.fn(async () => null));
152const mockOpenClerkWaitlist = vi.hoisted(() => vi.fn());
153
154vi.mock("@clerk/react", () => ({
155 useAuth: () => ({
156 getToken: mockGetClerkToken,
157 isSignedIn: false,
158 }),
159 useClerk: () => ({
160 openWaitlist: mockOpenClerkWaitlist,
161 }),
162}));
163
164vi.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
222function 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
254function 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
282function makeUtc(value: string) {
283 return DateTime.makeUnsafe(value);
284}
285
286function 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
300function 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
316function 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
351const 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
497describe("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
1313describe("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 @@
1import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";1import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";
2import { useQueryClient } from "@tanstack/react-query";
3import { Link } from "@tanstack/react-router";2import { Link } from "@tanstack/react-router";
4import { useCallback, useMemo, useRef, useState } from "react";3import { useCallback, useMemo, useRef, useState } from "react";
4import { useAtomValue } from "@effect/atom-react";
5import {5import {
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";
14import { scopeThreadRef } from "@t3tools/client-runtime";14import { scopeThreadRef } from "@t3tools/client-runtime/environment";
15import {
16 isAtomCommandInterrupted,
17 settlePromise,
18 squashAtomCommandFailure,
19} from "@t3tools/client-runtime/state/runtime";
15import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";20import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
16import { createModelSelection } from "@t3tools/shared/model";21import { createModelSelection } from "@t3tools/shared/model";
17import * as Arr from "effect/Array";22import * 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
33import { useTheme } from "../../hooks/useTheme";38import { useTheme } from "../../hooks/useTheme";
34import { useSettings, useUpdateSettings } from "../../hooks/useSettings";39import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
35import { useThreadActions } from "../../hooks/useThreadActions";40import { useThreadActions } from "../../hooks/useThreadActions";
36import {41import { useDesktopUpdateState } from "../../state/desktopUpdate";
37 setDesktopUpdateStateQueryData,
38 useDesktopUpdateState,
39} from "../../lib/desktopUpdateReactQuery";
40import {42import {
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";
48import { ensureLocalApi, readLocalApi } from "../../localApi";50import { ensureLocalApi, readLocalApi } from "../../localApi";
49import { useShallow } from "zustand/react/shallow";51import {
50import { selectProjectsAcrossEnvironments, useStore } from "../../store";52 primaryServerObservabilityAtom,
53 primaryServerProvidersAtom,
54 serverEnvironment,
55} from "../../state/server";
56import { usePrimaryEnvironment } from "../../state/environments";
57import { useProjects } from "../../state/entities";
51import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";58import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
52import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";59import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
53import { Button } from "../ui/button";60import { Button } from "../ui/button";
...@@ -78,7 +85,7 @@ import {...@@ -78,7 +85,7 @@ import {
78 useRelativeTimeTick,85 useRelativeTimeTick,
79} from "./settingsLayout";86} from "./settingsLayout";
80import { ProjectFavicon } from "../ProjectFavicon";87import { ProjectFavicon } from "../ProjectFavicon";
81import { useServerObservability, useServerProviders } from "../../rpc/serverState";88import { useAtomCommand } from "../../state/use-atom-command";
8289
83const THEME_OPTIONS = [90const THEME_OPTIONS = [
84 {91 {
...@@ -155,11 +162,9 @@ function AboutVersionTitle() {...@@ -155,11 +162,9 @@ function AboutVersionTitle() {
155}162}
156163
157function AboutVersionSection() {164function 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);
161167
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 bridge184 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 );
199201
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";
205207
206 if (action === "download") {208 if (action === "download") {
207 void bridge209 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 }
223220
...@@ -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 bridge228 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 }
247239
...@@ -249,7 +241,6 @@ function AboutVersionSection() {...@@ -249,7 +241,6 @@ function AboutVersionSection() {
249 void bridge241 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]);
274265
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() {
382export function useSettingsRestore(onRestored?: () => void) {373export 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();
386377
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) {
482export function GeneralSettingsPanel() {473export 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() {
918909
919export function ProviderSettingsPanel() {910export 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]);
971976
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 }
986993
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 Error1007 description:
999 ? error.message1008 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 );
10141025
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>
13321343
1333 <AddProviderInstanceDialog1344 {isAddInstanceDialogOpen ? (
1334 open={isAddInstanceDialogOpen}1345 <AddProviderInstanceDialog open onOpenChange={setIsAddInstanceDialogOpen} />
1335 onOpenChange={setIsAddInstanceDialogOpen}1346 ) : null}
1336 />
1337 </SettingsPageContainer>1347 </SettingsPageContainer>
1338 );1348 );
1339}1349}
13401350
1341export function ArchivedThreadsPanel() {1351export 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 );
14161426
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 }
14321443
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";
1414
15import { useSettings, useUpdateSettings } from "../../hooks/useSettings";15import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
16import { cn } from "../../lib/utils";16import { cn } from "../../lib/utils";
17import {17import { usePrimaryEnvironment } from "../../state/environments";
18 refreshSourceControlDiscovery,18import { useEnvironmentQuery } from "../../state/query";
19 useSourceControlDiscovery,19import { sourceControlEnvironment } from "../../state/sourceControl";
20} from "../../lib/sourceControlDiscoveryState";
21import { Badge } from "../ui/badge";20import { Badge } from "../ui/badge";
22import { Button } from "../ui/button";21import { Button } from "../ui/button";
23import { Collapsible, CollapsibleContent } from "../ui/collapsible";22import { Collapsible, CollapsibleContent } from "../ui/collapsible";
...@@ -293,7 +292,7 @@ function DiscoveryItemRow({...@@ -293,7 +292,7 @@ function DiscoveryItemRow({
293292
294function GitFetchIntervalSettings() {293function 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}
440439
441export function SourceControlSettingsPanel() {440export 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 @@
1import { useNavigate } from "@tanstack/react-router";1import { useNavigate } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";
2import type { ServerProvider } from "@t3tools/contracts";3import type { ServerProvider } from "@t3tools/contracts";
3import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react";4import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react";
4import { useCallback, useEffect, useState, type CSSProperties } from "react";5import { useCallback, useEffect, useState, type CSSProperties } from "react";
56
6import { useServerProviders } from "../../rpc/serverState";7import { primaryServerProvidersAtom } from "../../state/server";
7import {8import {
8 getProviderUpdateSidebarPillView,9 getProviderUpdateSidebarPillView,
9 type ProviderUpdateSidebarPillView,10 type ProviderUpdateSidebarPillView,
...@@ -39,7 +40,7 @@ function latestProviderCheckedAt(...@@ -39,7 +40,7 @@ function latestProviderCheckedAt(
3940
40export function SidebarProviderUpdatePill() {41export 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 @@
1import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react";1import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react";
2import { useQueryClient } from "@tanstack/react-query";
3import { useCallback, useState } from "react";2import { useCallback, useState } from "react";
4import { isElectron } from "../../env";3import { isElectron } from "../../env";
5import {4import { useDesktopUpdateState } from "../../state/desktopUpdate";
6 setDesktopUpdateStateQueryData,
7 useDesktopUpdateState,
8} from "../../lib/desktopUpdateReactQuery";
9import { stackedThreadToast, toastManager } from "../ui/toast";5import { stackedThreadToast, toastManager } from "../ui/toast";
10import {6import {
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";
22import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";18import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2319
24export function SidebarUpdatePill() {20export 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);
2823
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 bridge39 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 bridge75 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]);
107100
108 if (!visible && !showArm64Warning) return null;101 if (!visible && !showArm64Warning) return null;
109102
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";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
8import {8import {
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"
5959
60import {60import {
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 });
698699
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 });
9671002
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";
28import * as Schema from "effect/Schema";28import * as Schema from "effect/Schema";
29import * as Equal from "effect/Equal";29import * as Equal from "effect/Equal";
30import { DeepMutable } from "effect/Types";30import { DeepMutable } from "effect/Types";
...@@ -3347,6 +3347,60 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3347,6 +3347,60 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
33473347
3348export const useComposerDraftStore = composerDraftStore;3348export const useComposerDraftStore = composerDraftStore;
33493349
3350export 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
3350export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState {3404export 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
3459export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {3513export 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.threadId3519 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 @@
1import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections";
2
3import { connectionAtomRuntime } from "./runtime";
4
5export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime);
apps/web/src/connection/onboarding.ts created+38
...@@ -0,0 +1,38 @@
1import { ConnectionOnboarding } from "@t3tools/client-runtime/connection";
2import {
3 createAtomCommandScheduler,
4 createRuntimeCommand,
5} from "@t3tools/client-runtime/state/runtime";
6import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8
9import { connectionAtomRuntime } from "./runtime";
10
11const onboardingScheduler = createAtomCommandScheduler();
12
13export 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
29export 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 @@
1import {
2 AuthStandardClientScopes,
3 EnvironmentId,
4 type DesktopBridge,
5 type DesktopSshEnvironmentTarget,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9
10import { provisionDesktopSshEnvironment } from "./platform.ts";
11
12const TARGET: DesktopSshEnvironmentTarget = {
13 alias: "devbox",
14 hostname: "devbox.example.test",
15 username: "developer",
16 port: 22,
17};
18
19function 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
64describe("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 @@
1import {
2 ClientPresentation,
3 CloudSession,
4 EnvironmentOwnedDataCleanup,
5 PlatformConnectionSource,
6 RelayDeviceIdentity,
7 SshEnvironmentGateway,
8} from "@t3tools/client-runtime/platform";
9import {
10 ConnectionBlockedError,
11 ConnectionTransientError,
12 ConnectionWakeups,
13 Connectivity,
14 mapRemoteEnvironmentError,
15 PrimaryConnectionRegistration,
16 PrimaryConnectionTarget,
17} from "@t3tools/client-runtime/connection";
18import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
19import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
20import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc";
21import {
22 AuthStandardClientScopes,
23 type DesktopBridge,
24 type DesktopSshEnvironmentTarget,
25} from "@t3tools/contracts";
26import * as Context from "effect/Context";
27import * as Effect from "effect/Effect";
28import * as Layer from "effect/Layer";
29import * as Option from "effect/Option";
30import * as Queue from "effect/Queue";
31import * as Schedule from "effect/Schedule";
32import * as Stream from "effect/Stream";
33import { FetchHttpClient, HttpClient } from "effect/unstable/http";
34
35import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit";
36import { readPrimaryEnvironmentTarget } from "../environments/primary/target";
37import { clearComposerDraftsEnvironment } from "../composerDraftStore";
38import { isHostedStaticApp } from "../hostedPairing";
39import { appAtomRegistry } from "../rpc/atomRegistry";
40import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState";
41import { connectionStorageLayer } from "./storage";
42
43let nextObservedRpcRequestId = 0;
44
45function currentNetworkStatus(): "unknown" | "offline" | "online" {
46 if (typeof navigator === "undefined") {
47 return "unknown";
48 }
49 return navigator.onLine ? "online" : "offline";
50}
51
52const 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
75const 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
103function 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
113function 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
127export 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
160const 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
262const 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
288const primaryRegistrationRetrySchedule = Schedule.exponential("1 second").pipe(
289 Schedule.either(Schedule.spaced("16 seconds")),
290);
291
292const 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
319const environmentOwnedDataCleanupLayer = Layer.succeed(
320 EnvironmentOwnedDataCleanup,
321 EnvironmentOwnedDataCleanup.of({
322 clear: (environmentId) =>
323 Effect.sync(() => {
324 clearComposerDraftsEnvironment(environmentId);
325 }),
326 }),
327);
328
329const 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
344export 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 @@
1import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection";
2import * as Layer from "effect/Layer";
3import { Atom } from "effect/unstable/reactivity";
4
5import { runtimeContextLayer } from "../lib/runtime";
6import { connectionPlatformLayer } from "./platform";
7
8const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
9 Layer.provide(runtimeContextLayer),
10);
11
12export const connectionLayer = clientConnectionLayer.pipe(
13 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),
14);
15
16export const connectionAtomRuntime = Atom.runtime(connectionLayer);
apps/web/src/connection/storage.test.ts created+77
...@@ -0,0 +1,77 @@
1import { ConnectionTransientError } from "@t3tools/client-runtime/connection";
2import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform";
3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Schema from "effect/Schema";
6import { afterEach, vi } from "vite-plus/test";
7
8import { makeCatalogBackend, makeCatalogStore } from "./storage";
9
10const emptyCatalog = {
11 schemaVersion: 1,
12 targets: [],
13 profiles: [],
14 credentials: [],
15 remoteDpopTokens: [],
16} as const;
17const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument));
18
19afterEach(() => {
20 vi.unstubAllGlobals();
21 vi.restoreAllMocks();
22});
23
24describe("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
58describe("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 @@
1import {
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";
14import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization";
15import {
16 ConnectionCredentialStore,
17 ConnectionProfileStore,
18 ConnectionTransientError,
19} from "@t3tools/client-runtime/connection";
20import {
21 EnvironmentId,
22 OrchestrationShellSnapshot,
23 OrchestrationThread,
24 ThreadId,
25} from "@t3tools/contracts";
26import * as Context from "effect/Context";
27import * as Effect from "effect/Effect";
28import * as Layer from "effect/Layer";
29import * as Option from "effect/Option";
30import * as Ref from "effect/Ref";
31import * as Schema from "effect/Schema";
32import * as Semaphore from "effect/Semaphore";
33
34const DATABASE_NAME = "t3code:connection-runtime";
35const DATABASE_VERSION = 2;
36const CATALOG_STORE_NAME = "catalog";
37const SHELL_STORE_NAME = "shell";
38const THREAD_STORE_NAME = "thread";
39const CATALOG_KEY = "document";
40const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
41
42const StoredShellSnapshot = Schema.Struct({
43 schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
44 environmentId: EnvironmentId,
45 snapshot: OrchestrationShellSnapshot,
46});
47const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
48const StoredThreadSnapshot = Schema.Struct({
49 schemaVersion: Schema.Literal(1),
50 environmentId: EnvironmentId,
51 threadId: ThreadId,
52 thread: OrchestrationThread,
53});
54const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);
55const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);
56const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson);
57const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
58const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson);
59const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson);
60const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson);
61const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson);
62
63function 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
70function 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
89const 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
118function 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
130function 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
150function 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
165function 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
193function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) {
194 return `${environmentId}:${threadId}`;
195}
196
197const 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
203const 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
211export 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
217export 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
254interface CatalogStore {
255 readonly read: Effect.Effect<ConnectionCatalogDocumentType, ConnectionTransientError>;
256 readonly update: (
257 transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType,
258 ) => Effect.Effect<void, ConnectionTransientError>;
259}
260
261export 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
323export 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 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
44
apps/web/src/editorPreferences.ts+63-8
...@@ -1,9 +1,25 @@...@@ -1,9 +1,25 @@
1import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts";1import { EDITORS, EditorId, type EnvironmentId } from "@t3tools/contracts";
2import {
3 mapAtomCommandResult,
4 type AtomCommandFailure,
5 type AtomCommandResult,
6} from "@t3tools/client-runtime/state/runtime";
7import * as Cause from "effect/Cause";
8import * as Data from "effect/Data";
9import { AsyncResult } from "effect/unstable/reactivity";
2import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage";10import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage";
3import { useMemo } from "react";11import { useCallback, useMemo } from "react";
12import { shellEnvironment } from "./state/shell";
13import { useAtomCommand } from "./state/use-atom-command";
414
5const LAST_EDITOR_KEY = "t3code:last-editor";15const LAST_EDITOR_KEY = "t3code:last-editor";
616
17export class PreferredEditorUnavailableError extends Data.TaggedError(
18 "PreferredEditorUnavailableError",
19)<{
20 readonly message: string;
21}> {}
22
7export function usePreferredEditor(availableEditors: ReadonlyArray<EditorId>) {23export 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);
925
...@@ -26,10 +42,49 @@ export function resolveAndPersistPreferredEditor(...@@ -26,10 +42,49 @@ export function resolveAndPersistPreferredEditor(
26 return editor ?? null;42 return editor ?? null;
27}43}
2844
29export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise<EditorId> {45export 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 @@
1import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts";
2
3import type { WsRpcClient } from "@t3tools/client-runtime";
4import { readEnvironmentConnection } from "./environments/runtime";
5
6const environmentApiOverridesForTests = new Map<EnvironmentId, EnvironmentApi>();
7
8export 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
86export 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
104export 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
112export function __setEnvironmentApiOverrideForTests(
113 environmentId: EnvironmentId,
114 api: EnvironmentApi,
115): void {
116 environmentApiOverridesForTests.set(environmentId, api);
117}
118
119export function __resetEnvironmentApiOverridesForTests(): void {
120 environmentApiOverridesForTests.clear();
121}
apps/web/src/environmentGrouping.test.ts+82-574
...@@ -1,54 +1,40 @@...@@ -1,54 +1,40 @@
1import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";1import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts";
2import { scopeProjectRef } from "@t3tools/client-runtime";
3import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
43
5import {
6 selectProjectsAcrossEnvironments,
7 selectSidebarThreadsAcrossEnvironments,
8 selectSidebarThreadsForProjectRef,
9 selectSidebarThreadsForProjectRefs,
10 type AppState,
11 type EnvironmentState,
12} from "./store";
13import {4import {
14 deriveLogicalProjectKey,5 deriveLogicalProjectKey,
15 deriveLogicalProjectKeyFromSettings,6 deriveLogicalProjectKeyFromSettings,
16 derivePhysicalProjectKey,7 derivePhysicalProjectKey,
17 resolveProjectGroupingMode,8 resolveProjectGroupingMode,
18} from "./logicalProject";9} from "./logicalProject";
19import type { Project, SidebarThreadSummary } from "./types";10import type { Project } from "./types";
20import { DEFAULT_INTERACTION_MODE } from "./types";11
2112const primaryEnvironmentId = EnvironmentId.make("env-primary");
22// ── Fixture Identifiers ──────────────────────────────────────────────13const remoteEnvironmentId = EnvironmentId.make("env-remote");
2314const repositoryIdentity = {
24const primaryEnvId = EnvironmentId.make("env-primary");15 canonicalKey: "github.com/example/shared-repo",
25const remoteEnvId = EnvironmentId.make("env-remote");16 locator: {
2617 source: "git-remote" as const,
27const sharedProjectPrimaryId = ProjectId.make("shared-proj-primary");18 remoteName: "origin",
28const sharedProjectRemoteId = ProjectId.make("shared-proj-remote");19 remoteUrl: "https://github.com/example/shared-repo.git",
29const localOnlyProjectId = ProjectId.make("local-only-proj");20 },
30const remoteOnlyProjectId = ProjectId.make("remote-only-proj");21};
3122const defaultGroupingSettings = {
32const threadP1 = ThreadId.make("thread-shared-primary-1");
33const threadP2 = ThreadId.make("thread-shared-primary-2");
34const threadR1 = ThreadId.make("thread-shared-remote-1");
35const threadL1 = ThreadId.make("thread-local-only-1");
36const threadRO1 = ThreadId.make("thread-remote-only-1");
37
38const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo";
39const DEFAULT_GROUPING_SETTINGS = {
40 sidebarProjectGroupingMode: "repository" as const,23 sidebarProjectGroupingMode: "repository" as const,
41 sidebarProjectGroupingOverrides: {},24 sidebarProjectGroupingOverrides: {},
42};25};
4326
44// ── Factory Helpers ──────────────────────────────────────────────────27function makeProject(overrides: Partial<Project> = {}): Project {
45
46function 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}
5844
59function 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
80function 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
104function 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
225describe("environment grouping", () => {45describe("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 });
41053
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 });
45157
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 });
46164
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 });
47569
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 });
48972
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 });
49680
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 });
54084
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 ref92 ).toBe(physicalKey);
549 expect(threads).toHaveLength(2);
550 expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]);
551 });
552 });93 });
55394
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 });
58597
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",
589101 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);
600107
601 // Local-only group108 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);
606111
607 // Remote-only group112 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";
6import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";6import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import { HttpClientError } from "effect/unstable/http";8import { HttpClientError } from "effect/unstable/http";
9import { create } from "zustand";
109
11import { BootstrapHttpError, retryTransientBootstrap } from "./auth";10import { BootstrapHttpError, retryTransientBootstrap } from "./auth";
12import { PrimaryEnvironmentHttpClient } from "./httpClient";11import { PrimaryEnvironmentHttpClient } from "./httpClient";
...@@ -14,18 +13,7 @@ import { PrimaryEnvironmentHttpClient } from "./httpClient";...@@ -14,18 +13,7 @@ import { PrimaryEnvironmentHttpClient } from "./httpClient";
14import { runPrimaryHttp } from "../../lib/runtime";13import { runPrimaryHttp } from "../../lib/runtime";
15import { readPrimaryEnvironmentTarget } from "./target";14import { readPrimaryEnvironmentTarget } from "./target";
1615
17interface PrimaryEnvironmentBootstrapState {16let primaryEnvironmentDescriptor: ExecutionEnvironmentDescriptor | null = null;
18 readonly descriptor: ExecutionEnvironmentDescriptor | null;
19 readonly setDescriptor: (descriptor: ExecutionEnvironmentDescriptor | null) => void;
20 readonly reset: () => void;
21}
22
23const usePrimaryEnvironmentBootstrapStore = create<PrimaryEnvironmentBootstrapState>()((set) => ({
24 descriptor: null,
25 setDescriptor: (descriptor) => set({ descriptor }),
26 reset: () => set({ descriptor: null }),
27}));
28
29let primaryEnvironmentDescriptorPromise: Promise<ExecutionEnvironmentDescriptor> | null = null;17let primaryEnvironmentDescriptorPromise: Promise<ExecutionEnvironmentDescriptor> | null = null;
3018
31function createPrimaryKnownEnvironment(input: {19function createPrimaryKnownEnvironment(input: {
...@@ -72,17 +60,13 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironment...@@ -72,17 +60,13 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironment
72}60}
7361
74export function readPrimaryEnvironmentDescriptor(): ExecutionEnvironmentDescriptor | null {62export function readPrimaryEnvironmentDescriptor(): ExecutionEnvironmentDescriptor | null {
75 return usePrimaryEnvironmentBootstrapStore.getState().descriptor;63 return primaryEnvironmentDescriptor;
76}
77
78export function usePrimaryEnvironmentId(): EnvironmentId | null {
79 return usePrimaryEnvironmentBootstrapStore((state) => state.descriptor?.environmentId ?? null);
80}64}
8165
82export function writePrimaryEnvironmentDescriptor(66export function writePrimaryEnvironmentDescriptor(
83 descriptor: ExecutionEnvironmentDescriptor | null,67 descriptor: ExecutionEnvironmentDescriptor | null,
84): void {68): void {
85 usePrimaryEnvironmentBootstrapStore.getState().setDescriptor(descriptor);69 primaryEnvironmentDescriptor = descriptor;
86}70}
8771
88export function getPrimaryKnownEnvironment(): KnownEnvironment | null {72export function getPrimaryKnownEnvironment(): KnownEnvironment | null {
...@@ -118,7 +102,7 @@ export function resolveInitialPrimaryEnvironmentDescriptor(): Promise<ExecutionE...@@ -118,7 +102,7 @@ export function resolveInitialPrimaryEnvironmentDescriptor(): Promise<ExecutionE
118102
119export function __resetPrimaryEnvironmentBootstrapForTests(): void {103export function __resetPrimaryEnvironmentBootstrapForTests(): void {
120 primaryEnvironmentDescriptorPromise = null;104 primaryEnvironmentDescriptorPromise = null;
121 usePrimaryEnvironmentBootstrapStore.getState().reset();105 primaryEnvironmentDescriptor = null;
122}106}
123107
124export const resetPrimaryEnvironmentDescriptorForTests = __resetPrimaryEnvironmentBootstrapForTests;108export const resetPrimaryEnvironmentDescriptorForTests = __resetPrimaryEnvironmentBootstrapForTests;
apps/web/src/environments/primary/httpClient.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime";1import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
2import * as Context from "effect/Context";2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * 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 @@
1import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts";1import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts";
2import type { KnownEnvironment } from "@t3tools/client-runtime";
32
4export interface PrimaryEnvironmentTarget {3export 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}
810
9const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);11const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
apps/web/src/environments/runtime/catalog.test.ts deleted-188
...@@ -1,188 +0,0 @@
1import {
2 EnvironmentId,
3 type LocalApi,
4 type PersistedSavedEnvironmentRecord,
5} from "@t3tools/contracts";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8import {
9 readSavedEnvironmentCredential,
10 resetSavedEnvironmentRegistryStoreForTests,
11 resetSavedEnvironmentRuntimeStoreForTests,
12 useSavedEnvironmentRegistryStore,
13 useSavedEnvironmentRuntimeStore,
14 waitForSavedEnvironmentRegistryHydration,
15 writeSavedEnvironmentCredential,
16} from "./catalog";
17
18let resolveRegistryRead: () => void = () => {
19 throw new Error("Registry read resolver was not initialized.");
20};
21
22describe("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 @@
1import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime";
2import type {
3 AuthEnvironmentScope,
4 EnvironmentId,
5 ExecutionEnvironmentDescriptor,
6 PersistedSavedEnvironmentRecord,
7 ServerConfig,
8} from "@t3tools/contracts";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11import { create } from "zustand";
12
13import { ensureLocalApi } from "../../localApi";
14import { getPrimaryKnownEnvironment } from "../primary";
15
16export 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
27export 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]);
39export type SavedEnvironmentCredential = typeof SavedEnvironmentCredential.Type;
40
41const SavedEnvironmentCredentialJson = Schema.fromJsonString(SavedEnvironmentCredential);
42const decodeSavedEnvironmentCredentialJson = Schema.decodeUnknownOption(
43 SavedEnvironmentCredentialJson,
44);
45const encodeSavedEnvironmentCredentialJson = Schema.encodeSync(SavedEnvironmentCredentialJson);
46
47interface SavedEnvironmentRegistryState {
48 readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>;
49}
50
51interface 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
59let savedEnvironmentRegistryHydrated = false;
60let savedEnvironmentRegistryHydrationPromise: Promise<void> | null = null;
61
62export 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
77function valuesOfSavedEnvironmentRegistry(
78 byId: Record<EnvironmentId, SavedEnvironmentRecord>,
79): ReadonlyArray<SavedEnvironmentRecord> {
80 return Object.values(byId) as ReadonlyArray<SavedEnvironmentRecord>;
81}
82
83function 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
101function 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
114async 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
143export 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
203export function hasSavedEnvironmentRegistryHydrated(): boolean {
204 return savedEnvironmentRegistryHydrated;
205}
206
207export function waitForSavedEnvironmentRegistryHydration(): Promise<void> {
208 if (hasSavedEnvironmentRegistryHydrated()) {
209 return Promise.resolve();
210 }
211
212 return hydrateSavedEnvironmentRegistry();
213}
214
215export function listSavedEnvironmentRecords(): ReadonlyArray<SavedEnvironmentRecord> {
216 return Object.values(useSavedEnvironmentRegistryStore.getState().byId).toSorted((left, right) =>
217 left.label.localeCompare(right.label),
218 );
219}
220
221export function getSavedEnvironmentRecord(
222 environmentId: EnvironmentId,
223): SavedEnvironmentRecord | null {
224 return useSavedEnvironmentRegistryStore.getState().byId[environmentId] ?? null;
225}
226
227export 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
236export 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
254export function resetSavedEnvironmentRegistryStoreForTests() {
255 savedEnvironmentRegistryHydrated = false;
256 savedEnvironmentRegistryHydrationPromise = null;
257 useSavedEnvironmentRegistryStore.setState({ byId: {} });
258}
259
260export 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
271export async function readSavedEnvironmentBearerToken(
272 environmentId: EnvironmentId,
273): Promise<string | null> {
274 return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId);
275}
276
277export 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
292export 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
302export async function writeSavedEnvironmentBearerToken(
303 environmentId: EnvironmentId,
304 bearerToken: string,
305): Promise<boolean> {
306 return ensureLocalApi().persistence.setSavedEnvironmentSecret(environmentId, bearerToken);
307}
308
309export async function removeSavedEnvironmentBearerToken(
310 environmentId: EnvironmentId,
311): Promise<void> {
312 await ensureLocalApi().persistence.removeSavedEnvironmentSecret(environmentId);
313}
314
315export type SavedEnvironmentConnectionState = "connecting" | "connected" | "disconnected" | "error";
316
317export type SavedEnvironmentAuthState = "authenticated" | "requires-auth" | "unknown";
318
319export 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
331interface 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
342const 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
354function createDefaultSavedEnvironmentRuntimeState(): SavedEnvironmentRuntimeState {
355 return {
356 ...DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE,
357 };
358}
359
360export 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
399export function getSavedEnvironmentRuntimeState(
400 environmentId: EnvironmentId,
401): SavedEnvironmentRuntimeState {
402 return (
403 useSavedEnvironmentRuntimeStore.getState().byId[environmentId] ??
404 DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE
405 );
406}
407
408export function resetSavedEnvironmentRuntimeStoreForTests() {
409 useSavedEnvironmentRuntimeStore.getState().reset();
410}
apps/web/src/environments/runtime/connection.test.ts deleted-295
...@@ -1,295 +0,0 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import { createEnvironmentConnection } from "./connection";
5import type { WsRpcClient } from "@t3tools/client-runtime";
6
7function 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
139describe("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 @@
1export {
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 @@
1export {
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
17export {
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 @@
1import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import * as Tracer from "effect/Tracer";
6import { Headers } from "effect/unstable/http";
7import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
8
9import { RelayClientTracer } from "@t3tools/shared/relayTracing";
10
11const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError);
12
13let mockSavedRecords: Array<Record<string, unknown>> = [];
14
15const mockResolveRemotePairingTarget = vi.fn();
16const mockFetchRemoteEnvironmentDescriptor = vi.fn();
17const mockBootstrapRemoteBearerSession = vi.fn();
18const mockFetchRemoteSessionState = vi.fn();
19const mockFetchRemoteDpopSessionState = vi.fn();
20const mockResolveRemoteDpopWebSocketConnectionUrl = vi.fn();
21const mockResolveRemoteWebSocketConnectionUrl = vi.fn();
22const mockWsTransportConnectors: Array<() => Promise<string>> = [];
23let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner;
24let mockRelayClientTracer = Option.none<Tracer.Tracer>();
25const 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);
39const mockBootstrapSshBearerSession = vi.fn();
40const mockFetchSshSessionState = vi.fn();
41const mockPersistSavedEnvironmentRecord = vi.fn();
42const mockWriteSavedEnvironmentBearerToken = vi.fn();
43const mockWriteSavedEnvironmentCredential = vi.fn();
44const mockSetSavedEnvironmentRegistry = vi.fn();
45const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => {
46 return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null;
47});
48const mockReadSavedEnvironmentBearerToken = vi.fn();
49const mockReadSavedEnvironmentCredential = vi.fn();
50const mockRemoveSavedEnvironmentBearerToken = vi.fn();
51const mockPatchRuntime = vi.fn();
52const mockClearRuntime = vi.fn();
53const mockRegistrySetState = vi.fn((next: { byId: Record<string, Record<string, unknown>> }) => {
54 mockSavedRecords = Object.values(next.byId);
55});
56const mockRemove = vi.fn((environmentId: EnvironmentId) => {
57 mockSavedRecords = mockSavedRecords.filter((record) => record.environmentId !== environmentId);
58});
59const mockMarkConnected = vi.fn((environmentId: EnvironmentId, connectedAt: string) => {
60 mockSavedRecords = mockSavedRecords.map((record) =>
61 record.environmentId === environmentId ? { ...record, lastConnectedAt: connectedAt } : record,
62 );
63});
64const mockRename = vi.fn((environmentId: EnvironmentId, label: string) => {
65 mockSavedRecords = mockSavedRecords.map((record) =>
66 record.environmentId === environmentId ? { ...record, label } : record,
67 );
68});
69const mockUpsert = vi.fn((record: Record<string, unknown>) => {
70 mockSavedRecords = [
71 ...mockSavedRecords.filter((entry) => entry.environmentId !== record.environmentId),
72 record,
73 ];
74});
75const mockListSavedEnvironmentRecords = vi.fn(() => mockSavedRecords);
76const mockEnsureSshEnvironment = vi.fn();
77const mockDisconnectSshEnvironment = vi.fn();
78const mockFetchSshEnvironmentDescriptor = vi.fn();
79const mockToPersistedSavedEnvironmentRecord = vi.fn((record) => record);
80const mockCreateEnvironmentConnection = vi.fn();
81const mockClientGetConfig = vi.fn(async () => ({
82 environment: {
83 environmentId: EnvironmentId.make("environment-1"),
84 label: "Remote environment",
85 },
86}));
87const mockConnectManagedCloudEnvironment = vi.fn();
88const mockReadManagedRelayClerkToken = vi.fn();
89
90vi.mock("@t3tools/shared/remote", async (importOriginal) => ({
91 ...(await importOriginal<typeof import("@t3tools/shared/remote")>()),
92 resolveRemotePairingTarget: mockResolveRemotePairingTarget,
93}));
94
95vi.mock("../../lib/runtime", () => ({
96 webRuntime: {
97 runPromise: mockRemoteHttpRunPromise,
98 },
99}));
100
101vi.mock("../../cloud/linkEnvironment", () => ({
102 connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment,
103}));
104
105vi.mock("../../cloud/managedAuth", () => ({
106 readManagedRelayClerkToken: mockReadManagedRelayClerkToken,
107}));
108
109vi.mock("~/localApi", () => ({
110 ensureLocalApi: () => ({
111 persistence: {
112 setSavedEnvironmentRegistry: mockSetSavedEnvironmentRegistry,
113 },
114 }),
115}));
116
117vi.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
148vi.mock("./connection", async (importOriginal) => ({
149 ...(await importOriginal<typeof import("./connection")>()),
150 createEnvironmentConnection: mockCreateEnvironmentConnection,
151}));
152
153vi.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
181vi.mock("../../rpc/wsTransport", () => ({
182 WsTransport: vi.fn(function WsTransport(connect: () => Promise<string>) {
183 mockWsTransportConnectors.push(connect);
184 return {};
185 }),
186}));
187
188describe("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 @@
1import { QueryClient } from "@tanstack/react-query";
2import { EnvironmentId } from "@t3tools/contracts";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5const mockCreateEnvironmentConnection = vi.fn();
6const mockCreateWsRpcClient = vi.fn();
7const mockFetchRemoteSessionState = vi.fn();
8const mockResolveRemoteWebSocketConnectionUrl = vi.fn(() => "ws://remote.example.test");
9const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect);
10const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();
11const mockListSavedEnvironmentRecords = vi.fn();
12const mockSavedEnvironmentRegistrySubscribe = vi.fn();
13const mockReadSavedEnvironmentBearerToken = vi.fn();
14const mockReadSavedEnvironmentCredential = vi.fn();
15const mockGetSavedEnvironmentRecord = vi.fn();
16
17function MockWsTransport() {
18 return undefined;
19}
20
21vi.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
34vi.mock("../../lib/runtime", () => ({
35 webRuntime: {
36 runPromise: mockRemoteHttpRunPromise,
37 },
38}));
39
40vi.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
69vi.mock("./connection", async (importOriginal) => ({
70 ...(await importOriginal<typeof import("./connection")>()),
71 createEnvironmentConnection: mockCreateEnvironmentConnection,
72}));
73
74vi.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
84vi.mock("../../rpc/wsTransport", () => ({
85 WsTransport: MockWsTransport,
86}));
87
88vi.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
99vi.mock("~/localApi", () => ({
100 ensureLocalApi: vi.fn(() => ({
101 persistence: {
102 setSavedEnvironmentRegistry: vi.fn(async () => undefined),
103 },
104 })),
105}));
106
107vi.mock("~/lib/terminalStateCleanup", () => ({
108 collectActiveTerminalThreadIds: vi.fn(() => []),
109}));
110
111vi.mock("~/orchestrationEventEffects", () => ({
112 deriveOrchestrationBatchEffects: vi.fn(() => ({
113 promotedThreadRefs: [],
114 invalidatedProviderState: false,
115 })),
116}));
117
118vi.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
133vi.mock("~/terminalStateStore", () => ({
134 useTerminalStateStore: {
135 getState: () => ({
136 applyTerminalEvent: vi.fn(),
137 removeTerminalState: vi.fn(),
138 clearTerminalSelection: vi.fn(),
139 }),
140 },
141}));
142
143vi.mock("~/uiStateStore", () => ({
144 useUiStateStore: {
145 getState: () => ({
146 clearThreadUi: vi.fn(),
147 syncPromotedDraftThreadRefs: vi.fn(),
148 }),
149 },
150}));
151
152const 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
159const configSnapshot = {
160 environment: {
161 environmentId: savedRecord.environmentId,
162 label: "Remote environment",
163 },
164};
165
166function 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
223describe("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 @@
1import { QueryClient } from "@tanstack/react-query";
2import type { WsRpcClient } from "@t3tools/client-runtime";
3import {
4 EnvironmentId,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8 TurnId,
9 type OrchestrationShellSnapshot,
10} from "@t3tools/contracts";
11import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
12
13const mockSubscribeThread = vi.fn();
14const mockThreadUnsubscribe = vi.fn();
15const mockCreateEnvironmentConnection = vi.fn();
16const mockCreateWsRpcClient = vi.fn();
17const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();
18const mockListSavedEnvironmentRecords = vi.fn();
19const mockGetSavedEnvironmentRecord = vi.fn();
20const mockReadSavedEnvironmentBearerToken = vi.fn();
21const mockReadSavedEnvironmentCredential = vi.fn();
22const mockSavedEnvironmentRegistrySubscribe = vi.fn();
23const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn());
24const mockFetchRemoteSessionState = vi.fn();
25const mockResolveRemoteWebSocketConnectionUrl = vi.fn(async () => "ws://remote.example.test/ws");
26const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect);
27const mockConnectionReconnects: Array<ReturnType<typeof vi.fn>> = [];
28let savedEnvironmentRegistryListener: (() => void) | null = null;
29
30function MockWsTransport() {
31 return undefined;
32}
33
34vi.mock("../primary", () => ({
35 getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment,
36}));
37
38vi.mock("../../lib/runtime", () => ({
39 webRuntime: {
40 runPromise: mockRemoteHttpRunPromise,
41 },
42}));
43
44vi.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
73vi.mock("./connection", async (importOriginal) => ({
74 ...(await importOriginal<typeof import("./connection")>()),
75 createEnvironmentConnection: mockCreateEnvironmentConnection,
76}));
77
78vi.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
186vi.mock("../../rpc/wsTransport", () => ({
187 WsTransport: MockWsTransport,
188}));
189
190function 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
258describe("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 @@
1import {
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";
13import {
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
26import { type QueryClient } from "@tanstack/react-query";
27import { Throttler } from "@tanstack/react-pacer";
28import * as Effect from "effect/Effect";
29import * as Option from "effect/Option";
30import * as Schema from "effect/Schema";
31import { Headers, HttpTraceContext } from "effect/unstable/http";
32import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
33import {
34 createKnownEnvironment,
35 getKnownEnvironmentWsBaseUrl,
36 scopedThreadKey,
37 scopeProjectRef,
38 scopeThreadRef,
39} from "@t3tools/client-runtime";
40
41import {
42 markPromotedDraftThreadByRef,
43 markPromotedDraftThreadsByRef,
44 useComposerDraftStore,
45} from "~/composerDraftStore";
46import { ensureLocalApi } from "~/localApi";
47import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup";
48import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";
49import { getPrimaryKnownEnvironment } from "../primary";
50import { webRuntime } from "../../lib/runtime";
51import { connectManagedCloudEnvironment } from "../../cloud/linkEnvironment";
52import { readManagedRelayClerkToken } from "../../cloud/managedAuth";
53
54import {
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";
70import {
71 createEnvironmentConnection,
72 createEnvironmentConnectionAttemptRegistry,
73 EnvironmentConnectionAttemptCancelledError,
74 type EnvironmentConnection,
75} from "./connection";
76import {
77 useStore,
78 selectProjectsAcrossEnvironments,
79 selectSidebarThreadSummaryByRef,
80 selectThreadByRef,
81 selectThreadsAcrossEnvironments,
82} from "~/store";
83import { useTerminalUiStateStore } from "~/terminalUiStateStore";
84import { useUiStateStore } from "~/uiStateStore";
85import { getServerConfig } from "../../rpc/serverState";
86import { WsTransport } from "~/rpc/wsTransport";
87import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew";
88import {
89 deriveLogicalProjectKeyFromSettings,
90 derivePhysicalProjectKey,
91} from "../../logicalProject";
92
93const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope));
94import { getClientSettings } from "~/hooks/useSettings";
95import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState";
96import { subscribePortDiscovery, usePortDiscoveryStore } from "../../portDiscoveryState";
97import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState";
98import { resolveRemotePairingTarget } from "@t3tools/shared/remote";
99
100type EnvironmentServiceState = {
101 readonly queryClient: QueryClient;
102 readonly queryInvalidationThrottler: Throttler<() => void>;
103 refCount: number;
104 stop: () => void;
105};
106
107type 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
117const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>();
118const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
119
120function isSavedEnvironmentConnectionCancelledError(
121 error: unknown,
122): error is EnvironmentConnectionAttemptCancelledError {
123 return error instanceof EnvironmentConnectionAttemptCancelledError;
124}
125
126interface PendingSavedEnvironmentConnection {
127 readonly isCurrent: () => boolean;
128 readonly promise: Promise<EnvironmentConnection>;
129}
130
131const savedEnvironmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry();
132const pendingSavedEnvironmentConnections = new Map<
133 EnvironmentId,
134 PendingSavedEnvironmentConnection
135>();
136const environmentConnectionListeners = new Set<() => void>();
137const providerInvalidationListeners = new Set<() => void>();
138const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>();
139const lastAppliedProjectionVersionByEnvironment = new Map<
140 EnvironmentId,
141 {
142 readonly sequence: number;
143 readonly updatedAt: string | null;
144 }
145>();
146const terminalMetadataSubscriptions = new Map<EnvironmentId, () => void>();
147const portDiscoverySubscriptions = new Map<EnvironmentId, () => void>();
148
149let activeService: EnvironmentServiceState | null = null;
150let needsProviderInvalidation = false;
151let lastBrowserHiddenAt: number | null = null;
152let 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.
168const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000;
169const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32;
170const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000;
171const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150;
172const NOOP = () => undefined;
173const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;
174
175const createManagedRelayDpopProof = (input: ManagedRelayDpopProofInput) =>
176 Effect.gen(function* () {
177 const signer = yield* ManagedRelayDpopSigner;
178 return yield* signer.createProof(input);
179 });
180
181function 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
196async 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
215function 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}
241function 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
258function 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
270export 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
284export 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
298function readLastAppliedProjectionVersion(environmentId: EnvironmentId): {
299 readonly sequence: number;
300 readonly updatedAt: string | null;
301} | null {
302 return lastAppliedProjectionVersionByEnvironment.get(environmentId) ?? null;
303}
304
305function 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
321function 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}
332function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string {
333 return scopedThreadKey(scopeThreadRef(environmentId, threadId));
334}
335
336function 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
346function 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
391function shouldEvictThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean {
392 return entry.refCount === 0 && !isNonIdleThreadDetailSubscription(entry);
393}
394
395function 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
422function 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
435function 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
450function disposeThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void {
451 for (const [key, entry] of threadDetailSubscriptions) {
452 if (entry.environmentId === environmentId) {
453 disposeThreadDetailSubscriptionByKey(key);
454 }
455 }
456}
457
458function 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
469function attachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void {
470 for (const entry of threadDetailSubscriptions.values()) {
471 if (entry.environmentId === environmentId) {
472 attachThreadDetailSubscription(entry);
473 }
474 }
475}
476
477function 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
489function 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
513function 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
530function reconcileThreadDetailSubscriptionEvictionState(
531 entry: ThreadDetailSubscriptionEntry,
532): void {
533 clearThreadDetailSubscriptionEviction(entry);
534 if (!shouldEvictThreadDetailSubscription(entry)) {
535 return;
536 }
537
538 scheduleThreadDetailSubscriptionEviction(entry);
539}
540
541function 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
555function 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
566export 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
624function emitEnvironmentConnectionRegistryChange() {
625 for (const listener of environmentConnectionListeners) {
626 listener();
627 }
628}
629
630function emitProviderInvalidation() {
631 for (const listener of providerInvalidationListeners) {
632 listener();
633 }
634}
635
636function getRuntimeErrorFields(error: unknown) {
637 return {
638 lastError: error instanceof Error ? error.message : String(error),
639 lastErrorAt: new Date().toISOString(),
640 } as const;
641}
642
643function isoNow(): string {
644 return new Date().toISOString();
645}
646
647function 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
661function isSshHttpAuthError(error: unknown, status: number): boolean {
662 return readSshHttpErrorStatus(error) === status;
663}
664
665function 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
681function 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
695function 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
704type SavedEnvironmentRegistrySnapshot = ReadonlyMap<EnvironmentId, SavedEnvironmentRecord | null>;
705
706function 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
717async 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
737async 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
749function 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
757async function fetchDesktopSshEnvironmentDescriptor(httpBaseUrl: string) {
758 return await getDesktopSshBridge().fetchSshEnvironmentDescriptor(httpBaseUrl);
759}
760
761async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: string) {
762 return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential);
763}
764
765function readIssuedBearerScopes(scope: string): ReadonlyArray<AuthEnvironmentScope> {
766 return decodeIssuedBearerScopes(scope.split(" "));
767}
768
769async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) {
770 return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken);
771}
772
773async 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
784async 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
827async 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
872function setRuntimeConnecting(environmentId: EnvironmentId) {
873 useSavedEnvironmentRuntimeStore.getState().patch(environmentId, {
874 connectionState: "connecting",
875 lastError: null,
876 lastErrorAt: null,
877 });
878}
879
880function 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
893function 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
906function setRuntimeError(environmentId: EnvironmentId, error: unknown) {
907 useSavedEnvironmentRuntimeStore.getState().patch(environmentId, {
908 connectionState: "error",
909 ...getRuntimeErrorFields(error),
910 });
911}
912
913function 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
950function 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
962function 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
975function 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
991function 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
1063export function applyEnvironmentThreadDetailEvent(
1064 event: OrchestrationEvent,
1065 environmentId: EnvironmentId,
1066) {
1067 applyRecoveredEventBatch([event], environmentId);
1068}
1069
1070function 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
1120function 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
1150function createWsRpcClient(transport: WsTransport): WsRpcClient {
1151 return createBaseWsRpcClient(transport, {
1152 beforeReconnect: () => resetWsReconnectBackoff(),
1153 });
1154}
1155
1156function 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
1176function 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
1273async 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
1326const 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
1360async 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
1407function 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
1434async 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
1454function 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
1475function maybeCreatePrimaryEnvironmentConnection(): EnvironmentConnection | null {
1476 return getPrimaryKnownEnvironment()?.environmentId ? createPrimaryEnvironmentConnection() : null;
1477}
1478
1479async 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
1676async 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
1695function stopActiveService() {
1696 activeService?.stop();
1697 activeService = null;
1698}
1699
1700function 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
1721function 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
1752export function subscribeEnvironmentConnections(listener: () => void): () => void {
1753 environmentConnectionListeners.add(listener);
1754 return () => {
1755 environmentConnectionListeners.delete(listener);
1756 };
1757}
1758
1759export function subscribeProviderInvalidations(listener: () => void): () => void {
1760 providerInvalidationListeners.add(listener);
1761 return () => {
1762 providerInvalidationListeners.delete(listener);
1763 };
1764}
1765
1766export function listEnvironmentConnections(): ReadonlyArray<EnvironmentConnection> {
1767 return [...environmentConnections.values()];
1768}
1769
1770export function readEnvironmentConnection(
1771 environmentId: EnvironmentId,
1772): EnvironmentConnection | null {
1773 return environmentConnections.get(environmentId) ?? null;
1774}
1775
1776export 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
1784export function getPrimaryEnvironmentConnection(): EnvironmentConnection {
1785 return createPrimaryEnvironmentConnection();
1786}
1787
1788export 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
1808export 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
1860export 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
1869export 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
1938export 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
1976export 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
2005export async function ensureEnvironmentConnectionBootstrapped(
2006 environmentId: EnvironmentId,
2007): Promise<void> {
2008 await environmentConnections.get(environmentId)?.ensureBootstrapped();
2009}
2010
2011export 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
2080export 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 @@
1import { type ChangeEvent, type KeyboardEvent, useEffect, useRef, useState } from "react";1import { type ChangeEvent, type KeyboardEvent, useState } from "react";
22
3/**3/**
4 * Buffer text input locally so keystrokes don't cause a settings-wide4 * 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 */
18export function useCommitOnBlur(value: string, onCommit: (next: string) => void) {18export 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]);
2720
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 @@
1import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";1import {
2 scopedProjectKey,
3 scopeProjectRef,
4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";
2import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts";6import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts";
3import { useParams, useRouter } from "@tanstack/react-router";7import { useParams, useRouter } from "@tanstack/react-router";
4import { useCallback, useMemo } from "react";8import { useCallback, useMemo } from "react";
5import { useShallow } from "zustand/react/shallow";
6import {9import {
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";
18import { selectProjectsAcrossEnvironments, useStore } from "../store";22import { readThreadShell, useProjects, useThread } from "../state/entities";
19import { createThreadSelectorByRef } from "../storeSelectors";
20import { resolveThreadRouteTarget } from "../threadRoutes";23import { resolveThreadRouteTarget } from "../threadRoutes";
21import { useUiStateStore } from "../uiStateStore";24import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
22import { useSettings } from "./useSettings";25import { useSettings } from "./useSettings";
2326
24function useNewThreadState() {27export 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 = currentRouteTarget76 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.draftId100 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}
141159
142export function useNewThreadHandler() {
143 const handleNewThread = useNewThreadState();
144
145 return {
146 handleNewThread,
147 };
148}
149
150export function useHandleNewThread() {160export 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 routeTarget170 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();
177189
178 return {190 return {
179 activeDraftThread,191 activeDraftThread,
apps/web/src/hooks/useLocalStorage.ts+50-66
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import * as Record from "effect/Record";2import * as Record from "effect/Record";
3import { useCallback, useEffect, useRef, useState } from "react";3import { useCallback, useMemo, useSyncExternalStore } from "react";
44
5const isomorphicLocalStorage: Storage =5const 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 initialValue66 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]);
76110
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 updates121 }
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 );
98129
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 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import {2import { 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";
93
10import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage";4import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage";
115
...@@ -66,10 +60,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): {...@@ -66,10 +60,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
66 }60 }
67 });61 });
6862
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]);
7364
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 );
125116
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 );
171162
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 */
12import { useCallback, useMemo, useSyncExternalStore } from "react";12import { useCallback, useMemo, useSyncExternalStore } from "react";
13import { useAtomValue } from "@effect/atom-react";
13import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";14import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";
14import {15import {
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";
21import { ensureLocalApi } from "~/localApi";21import { ensureLocalApi } from "~/localApi";
22import * as Struct from "effect/Struct";22import * as Struct from "effect/Struct";
23import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";23import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
24import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState";24import { usePrimaryEnvironment } from "~/state/environments";
25import { useAtomCommand } from "~/state/use-atom-command";
2526
26const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";27const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
2728
...@@ -30,6 +31,7 @@ const clientSettingsHydrationListeners = new Set<() => void>();...@@ -30,6 +31,7 @@ const clientSettingsHydrationListeners = new Set<() => void>();
30let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;31let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
31let clientSettingsHydrated = false;32let clientSettingsHydrated = false;
32let clientSettingsHydrationPromise: Promise<void> | null = null;33let clientSettingsHydrationPromise: Promise<void> | null = null;
34let clientSettingsHydrationGeneration = 0;
3335
34function emitClientSettingsChange() {36function 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 }
9092
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 })();
103111
...@@ -168,7 +176,7 @@ export function useClientSettingsHydrated(): boolean {...@@ -168,7 +176,7 @@ export function useClientSettingsHydrated(): boolean {
168}176}
169177
170export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {178export 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 */
195export function useUpdateSettings() {203export function useUpdateSettings() {
196 const updateSettings = useCallback((patch: Partial<UnifiedSettings>) => {204 const persistServerSettings = useAtomCommand(
197 const { serverPatch, clientPatch } = splitPatch(patch);205 serverEnvironment.updateSettings,
198206 "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 }, []);
215221
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 );
219231
220 return {232 return updateSettings;
221 updateSettings,
222 resetSettings,
223 };
224}233}
225234
226export function __resetClientSettingsPersistenceForTests(): void {235export 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
244export 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 @@
1import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";1import {
2 parseScopedThreadKey,
3 scopeProjectRef,
4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";
6import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";7import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import * as Data from "effect/Data";
10import { AsyncResult } from "effect/unstable/reactivity";
3import { useRouter } from "@tanstack/react-router";11import { useRouter } from "@tanstack/react-router";
4import { useCallback, useRef } from "react";12import { useCallback, useMemo, useRef } from "react";
513
6import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";14import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
7import { useComposerDraftStore } from "../composerDraftStore";15import { useComposerDraftStore } from "../composerDraftStore";
16import { terminalEnvironment } from "../state/terminal";
17import { threadEnvironment } from "../state/threads";
18import { vcsEnvironment } from "../state/vcs";
8import { useNewThreadHandler } from "./useHandleNewThread";19import { useNewThreadHandler } from "./useHandleNewThread";
9import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";
10import { invalidateSourceControlState } from "../lib/sourceControlActions";
11import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";20import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
12import { newCommandId } from "../lib/utils";
13import { readLocalApi } from "../localApi";21import { readLocalApi } from "../localApi";
14import {22import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities";
15 selectProjectByRef,
16 selectThreadByRef,
17 selectThreadsForEnvironment,
18 useStore,
19} from "../store";
20import { useTerminalUiStateStore } from "../terminalUiStateStore";23import { useTerminalUiStateStore } from "../terminalUiStateStore";
21import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";24import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
22import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";25import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
23import { stackedThreadToast, toastManager } from "../components/ui/toast";26import { stackedThreadToast, toastManager } from "../components/ui/toast";
24import { useSettings } from "./useSettings";27import { useSettings } from "./useSettings";
28import { useAtomCommand } from "../state/use-atom-command";
29
30export class ThreadArchiveBlockedError extends Data.TaggedError("ThreadArchiveBlockedError")<{
31 readonly message: string;
32}> {}
2533
26export function useThreadActions() {34export 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 in61 // Keep a ref so archiveThread can call handleNewThread without appearing in
37 // its dependency array — handleNewThread is inherently unstable (depends on62 // its dependency array — handleNewThread is inherently unstable (depends on
38 // the projects list) and would otherwise cascade new references into every63 // 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;
4267
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() {
5882
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 }
6997
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 }
79109
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 }
83120
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 );
89126
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 );
100140
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 = orphanedWorktreePath181 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 ),
156197 );
157 if (thread.session && thread.session.status !== "closed") {198 if (confirmationResult._tag === "Failure") {
158 await api.orchestration199 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 }
167203
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 }
173210
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() {
197241
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 }
219277
220 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {278 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
221 return;279 return deleteResult;
222 }280 }
223281
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 );
260338
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);
267343
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 }
280361
281 await deleteThread(target);362 return deleteThread(target);
282 },363 },
283 [confirmThreadDelete, deleteThread, resolveThreadTarget],364 [confirmThreadDelete, deleteThread, resolveThreadTarget],
284 );365 );
285366
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 @@
1import { useMemo } from "react";1import { useMemo } from "react";
2import { inferCheckpointTurnCountByTurnId } from "../session-logic";2import { inferCheckpointTurnCountByTurnId } from "../session-logic";
3import type { Thread } from "../types";3import type { Thread, TurnDiffSummary } from "../types";
44
5export function useTurnDiffSummaries(activeThread: Thread | undefined) {5export 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]);
1212
13 const inferredCheckpointTurnCountByTurnId = useMemo(13 const inferredCheckpointTurnCountByTurnId = useMemo(
apps/web/src/lib/archivedThreadsState.ts+53-13
...@@ -1,23 +1,62 @@...@@ -1,23 +1,62 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
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";
8import type { EnvironmentId } from "@t3tools/contracts";7import type { EnvironmentId } from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import * as Option from "effect/Option";
10import { AsyncResult, Atom } from "effect/unstable/reactivity";
9import { useCallback, useMemo } from "react";11import { useCallback, useMemo } from "react";
1012
11import { readEnvironmentApi } from "../environmentApi";13import { orchestrationEnvironment } from "../state/orchestration";
12import { appAtomRegistry } from "../rpc/atomRegistry";14import { appAtomRegistry } from "../rpc/atomRegistry";
1315
14const archivedThreadsManager = createArchivedThreadsManager({16const 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
51function archivedSnapshotAtom(environmentId: EnvironmentId) {
52 return orchestrationEnvironment.archivedShellSnapshot({
53 environmentId,
54 input: {},
55 });
56}
1857
19export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void {58export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void {
20 archivedThreadsManager.refreshForEnvironment(environmentId);59 appAtomRegistry.refresh(archivedSnapshotAtom(environmentId));
21}60}
2261
23export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<EnvironmentId>): {62export 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]);
3878
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 @@
1import { scopeProjectRef } from "@t3tools/client-runtime";1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ProjectId } from "@t3tools/contracts";2import { EnvironmentId, ProjectId } from "@t3tools/contracts";
3import { describe, expect, it, vi } from "vite-plus/test";3import { describe, expect, it, vi } from "vite-plus/test";
4import {4import {
apps/web/src/lib/chatThreadActions.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { scopeProjectRef } from "@t3tools/client-runtime";1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts";2import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts";
3import type { DraftThreadEnvMode } from "../composerDraftStore";3import type { DraftThreadEnvMode } from "../composerDraftStore";
44
apps/web/src/lib/checkpointDiffState.ts+8-53
...@@ -1,63 +1,18 @@...@@ -1,63 +1,18 @@
1import { useAtomValue } from "@effect/atom-react";
2import {1import {
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";
11import { useEffect, useMemo } from "react";
125
13import { readEnvironmentApi } from "../environmentApi";6import { useCheckpointDiff as useCheckpointDiffQuery } from "../state/queries";
14import { subscribeProviderInvalidations } from "../environments/runtime";
15import { appAtomRegistry } from "../rpc/atomRegistry";
16
17const checkpointDiffManager = createCheckpointDiffManager({
18 getRegistry: () => appAtomRegistry,
19 getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null,
20});
21
22export function invalidateCheckpointDiffs(): void {
23 checkpointDiffManager.invalidate();
24}
25
26subscribeProviderInvalidations(invalidateCheckpointDiffs);
277
28export function useCheckpointDiff(8export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {1import {
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";
12import { useEffect, useMemo } from "react";
135
14import {6import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries";
15 readEnvironmentConnection,
16 subscribeEnvironmentConnections,
17 subscribeProviderInvalidations,
18} from "../environments/runtime";
19import { appAtomRegistry } from "../rpc/atomRegistry";
20
21const COMPOSER_PATH_SEARCH_LIMIT = 80;
22const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120;
23const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000;
24
25const 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
34export function invalidateComposerPathSearches(): void {
35 composerPathSearchManager.invalidate();
36}
37
38subscribeProviderInvalidations(invalidateComposerPathSearches);
397
40export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState {8export 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 @@
1import { QueryClient } from "@tanstack/react-query";
2import { describe, expect, it } from "vite-plus/test";
3import type { DesktopUpdateState } from "@t3tools/contracts";
4import {
5 desktopUpdateQueryKeys,
6 desktopUpdateStateQueryOptions,
7 setDesktopUpdateStateQueryData,
8} from "./desktopUpdateReactQuery";
9
10const 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
27describe("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
36describe("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 @@
1import { queryOptions, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
2import { useEffect } from "react";
3import type { DesktopUpdateState } from "@t3tools/contracts";
4
5export const desktopUpdateQueryKeys = {
6 all: ["desktop", "update"] as const,
7 state: () => ["desktop", "update", "state"] as const,
8};
9
10export const setDesktopUpdateStateQueryData = (
11 queryClient: QueryClient,
12 state: DesktopUpdateState | null,
13) => queryClient.setQueryData(desktopUpdateQueryKeys.state(), state);
14
15export 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
28export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import type {
3 ServerProcessDiagnosticsResult,
4 ServerProcessResourceHistoryResult,
5} from "@t3tools/contracts";
6import * as Cause from "effect/Cause";
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import { AsyncResult, Atom } from "effect/unstable/reactivity";
10import { useCallback } from "react";
11
12import { ensureLocalApi } from "../localApi";
13import { appAtomRegistry } from "../rpc/atomRegistry";
14
15const PROCESS_DIAGNOSTICS_STALE_TIME_MS = 2_000;
16const PROCESS_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000;
17const PROCESS_RESOURCE_HISTORY_STALE_TIME_MS = 5_000;
18const PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR = ":";
19
20const 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
31function 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
38function 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
49const 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
63export interface ProcessDiagnosticsState {
64 readonly data: ServerProcessDiagnosticsResult | null;
65 readonly error: string | null;
66 readonly isPending: boolean;
67 readonly refresh: () => void;
68}
69
70export interface ProcessResourceHistoryState {
71 readonly data: ServerProcessResourceHistoryResult | null;
72 readonly error: string | null;
73 readonly isPending: boolean;
74 readonly refresh: () => void;
75}
76
77function formatProcessDiagnosticsError(error: unknown): string {
78 return error instanceof Error ? error.message : "Failed to load process diagnostics.";
79}
80
81function 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
92function 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
103export function refreshProcessDiagnostics(): void {
104 appAtomRegistry.refresh(processDiagnosticsAtom);
105}
106
107export 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
122export 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";
2import type * as Effect from "effect/Effect";2import type * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import { FetchHttpClient } from "effect/unstable/http";4import { FetchHttpClient } from "effect/unstable/http";
5import * as Socket from "effect/unstable/socket/Socket";
56
6import { remoteHttpClientLayer } from "@t3tools/client-runtime";7import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
7import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";8import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
8import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing";9import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing";
9import {10import {
...@@ -13,22 +14,22 @@ import {...@@ -13,22 +14,22 @@ import {
13import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit";14import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit";
1415
15import { browserCryptoLayer } from "../cloud/dpop";16import { browserCryptoLayer } from "../cloud/dpop";
16import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer";17import { managedRelayClientLayer } from "../cloud/managedRelayLayer";
17import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig";18import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig";
1819
19function configuredRelayUrl(): string {20function configuredRelayUrl(): string {
20 return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid";21 return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid";
21}22}
2223
23const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch);24const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));
24const webRelayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), {25const 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));
3031
31export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer);32export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer);
3233
33const primaryHttpRuntime = ManagedRuntime.make(34const 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}
6061
61export const webRuntime = ManagedRuntime.make(62export 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
72export const runtime = ManagedRuntime.make(runtimeLayer);
73
74export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect);
apps/web/src/lib/sourceControlActions.ts+10-477
...@@ -1,477 +1,10 @@...@@ -1,477 +1,10 @@
1import { useAtomValue } from "@effect/atom-react";1export {
2import {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";
11import {
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";
23import {
24 useCallback,
25 useEffect,
26 useMemo,
27 useState,
28 useSyncExternalStore,
29 useTransition,
30} from "react";
31
32import { ensureEnvironmentApi } from "../environmentApi";
33import { readEnvironmentConnection } from "../environments/runtime";
34import { appAtomRegistry } from "../rpc/atomRegistry";
35import { getVcsStatusSnapshot, refreshVcsStatus } from "./vcsStatusState";
36import { vcsRefManager } from "./vcsRefState";
37
38type SourceControlActionKind =
39 | "init"
40 | "pull"
41 | "publishRepository"
42 | "runStackedAction"
43 | "preparePullRequestThread";
44
45interface SourceControlActionScope {
46 readonly environmentId: EnvironmentId | null;
47 readonly cwd: string | null;
48}
49
50interface 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
57export 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
66const actionListeners = new Set<() => void>();
67const activeActionCounts = new Map<string, number>();
68
69function notifyActionListeners(): void {
70 for (const listener of actionListeners) {
71 listener();
72 }
73}
74
75function subscribeActionState(listener: () => void): () => void {
76 actionListeners.add(listener);
77 return () => {
78 actionListeners.delete(listener);
79 };
80}
81
82function actionKey(kind: SourceControlActionKind, scope: SourceControlActionScope): string {
83 return `${kind}:${scope.environmentId ?? ""}:${scope.cwd ?? ""}`;
84}
85
86function 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
105function isAnyActionRunning(
106 kinds: ReadonlyArray<SourceControlActionKind>,
107 scope: SourceControlActionScope,
108): boolean {
109 return kinds.some((kind) => (activeActionCounts.get(actionKey(kind, scope)) ?? 0) > 0);
110}
111
112function 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
126function 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
134export 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
155function 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
203export 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
221function 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
263export 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
277export 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
321export 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
335export 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
362export 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
385interface PullRequestResolutionTarget {
386 readonly environmentId: EnvironmentId | null;
387 readonly cwd: string | null;
388 readonly reference: string | null;
389}
390
391interface PullRequestResolutionState {
392 readonly data: GitResolvePullRequestResult | null;
393 readonly error: unknown;
394 readonly isPending: boolean;
395 readonly isFetching: boolean;
396}
397
398const EMPTY_PULL_REQUEST_RESOLUTION: PullRequestResolutionState = {
399 data: null,
400 error: null,
401 isPending: false,
402 isFetching: false,
403};
404
405const pullRequestResolutionCache = new Map<string, GitResolvePullRequestResult>();
406
407function 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
414export function readCachedPullRequestResolution(
415 target: PullRequestResolutionTarget,
416): GitResolvePullRequestResult | null {
417 const key = pullRequestResolutionKey(target);
418 return key ? (pullRequestResolutionCache.get(key) ?? null) : null;
419}
420
421export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type SourceControlDiscoveryTarget,
4 type SourceControlDiscoveryState,
5 createSourceControlDiscoveryManager,
6 getSourceControlDiscoveryTargetKey,
7 sourceControlDiscoveryStateAtom,
8} from "@t3tools/client-runtime";
9import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts";
10import { useEffect } from "react";
11
12import { readPrimaryEnvironmentDescriptor } from "../environments/primary";
13import {
14 readEnvironmentConnection,
15 subscribeEnvironmentConnections,
16} from "../environments/runtime";
17import { readLocalApi } from "../localApi";
18import { appAtomRegistry } from "../rpc/atomRegistry";
19
20const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const;
21const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000;
22const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000;
23
24interface SourceControlDiscoveryTargetInput {
25 readonly environmentId?: EnvironmentId | null;
26}
27
28function 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
40const 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
69export function refreshSourceControlDiscovery(
70 input?: SourceControlDiscoveryTargetInput,
71): Promise<SourceControlDiscoveryResult | null> {
72 return sourceControlDiscoveryManager.refresh(sourceControlDiscoveryTarget(input));
73}
74
75export function getSourceControlDiscoverySnapshot(
76 input?: SourceControlDiscoveryTargetInput,
77): SourceControlDiscoveryState {
78 return sourceControlDiscoveryManager.getSnapshot(sourceControlDiscoveryTarget(input));
79}
80
81export function resetSourceControlDiscoveryStateForTests(): void {
82 sourceControlDiscoveryManager.reset();
83}
84
85export 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 @@
1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
44
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";
44
5export type ThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & {5export 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};
99
10export function toSortableTimestamp(iso: string | undefined): number | null {10export function toSortableTimestamp(iso: string | undefined): number | null {
apps/web/src/lib/traceDiagnosticsState.ts deleted-65
...@@ -1,65 +0,0 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { ServerTraceDiagnosticsResult } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import { AsyncResult, Atom } from "effect/unstable/reactivity";
7import { useCallback } from "react";
8
9import { ensureLocalApi } from "../localApi";
10import { appAtomRegistry } from "../rpc/atomRegistry";
11
12const TRACE_DIAGNOSTICS_STALE_TIME_MS = 5_000;
13const TRACE_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000;
14
15const 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
26export interface TraceDiagnosticsState {
27 readonly data: ServerTraceDiagnosticsResult | null;
28 readonly error: string | null;
29 readonly isPending: boolean;
30 readonly refresh: () => void;
31}
32
33function formatTraceDiagnosticsError(error: unknown): string {
34 return error instanceof Error ? error.message : "Failed to load trace diagnostics.";
35}
36
37function 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
48export function refreshTraceDiagnostics(): void {
49 appAtomRegistry.refresh(traceDiagnosticsAtom);
50}
51
52export 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";
5describe("summarizeTurnDiffStats", () => {5describe("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 ]);
1212
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", () => {
17describe("buildTurnDiffTree", () => {17describe("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 ]);
2424
25 expect(tree).toEqual([25 expect(tree).toEqual([
...@@ -60,10 +60,10 @@ describe("buildTurnDiffTree", () => {...@@ -60,10 +60,10 @@ describe("buildTurnDiffTree", () => {
60 ]);60 ]);
61 });61 });
6262
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 ]);
6868
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", () => {
9292
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 ]);
9797
98 expect(tree).toEqual([98 expect(tree).toEqual([
...@@ -115,8 +115,8 @@ describe("buildTurnDiffTree", () => {...@@ -115,8 +115,8 @@ describe("buildTurnDiffTree", () => {
115115
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 ]);
121121
122 expect(tree).toEqual([122 expect(tree).toEqual([
...@@ -153,8 +153,8 @@ describe("buildTurnDiffTree", () => {...@@ -153,8 +153,8 @@ describe("buildTurnDiffTree", () => {
153153
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 ]);
159159
160 expect(tree).toHaveLength(2);160 expect(tree).toHaveLength(2);
apps/web/src/lib/vcsRefState.ts deleted-48
...@@ -1,48 +0,0 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
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";
11import { useEffect, useMemo } from "react";
12
13import {
14 readEnvironmentConnection,
15 subscribeEnvironmentConnections,
16} from "../environments/runtime";
17import { appAtomRegistry } from "../rpc/atomRegistry";
18
19const VCS_REF_LIST_LIMIT = 100;
20const VCS_REF_STALE_TIME_MS = 5_000;
21
22export 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
33export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
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";
13import type { EnvironmentId } from "@t3tools/contracts";
14import { useEffect } from "react";
15
16import {
17 readEnvironmentConnection,
18 subscribeEnvironmentConnections,
19} from "../environments/runtime";
20import { appAtomRegistry } from "../rpc/atomRegistry";
21
22export type { VcsStatusState, VcsStatusTarget };
23export { getVcsStatusDataForTarget };
24
25const 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
38export function getVcsStatusSnapshot(target: VcsStatusTarget): VcsStatusState {
39 return manager.getSnapshot(target);
40}
41
42export function watchVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient): () => void {
43 return manager.watch(target, client);
44}
45
46export function refreshVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient) {
47 return manager.refresh(target, client);
48}
49
50export function resetVcsStatusStateForTests(): void {
51 manager.reset();
52}
53
54export 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 @@
1import {1import {
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";
17import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
187
19import type { ContextMenuItem } from "@t3tools/contracts";
20
21const showContextMenuFallbackMock =8const 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 >();
2815
29function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
30 listeners.add(listener);
31 return () => {
32 listeners.delete(listener);
33 };
34}
35
36const terminalAttachListeners = new Set<(event: TerminalAttachStreamEvent) => void>();
37const terminalMetadataListeners = new Set<(event: TerminalMetadataStreamEvent) => void>();
38const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>();
39const gitStatusListeners = new Set<(event: VcsStatusResult) => void>();
40
41const 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
136vi.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
161vi.mock("./contextMenuFallback", () => ({16vi.mock("./contextMenuFallback", () => ({
162 showContextMenuFallback: showContextMenuFallbackMock,17 showContextMenuFallback: showContextMenuFallbackMock,
163}));18}));
16419
165function emitEvent<T>(listeners: Set<(event: T) => void>, event: T) {
166 for (const listener of listeners) {
167 listener(event);
168 }
169}
170
171function 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
181function createLocalStorageStub(): Storage {20function 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}
20037
201function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {38function 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}
29041
291const 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
307const 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
320const 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
343const 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
356beforeEach(() => {42beforeEach(() => {
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});
37562
376describe("wsApi", () => {63describe("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();
61667
617 const api = createLocalApi(rpcClientMock as never);68 await expect(api.server.getConfig()).rejects.toThrow(
61869 "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 });
65175
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;
65880
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 });
66284
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 });
70096
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;
703100
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"));
714105
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 });
723111
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 };
745119
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");
776123
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");
778128
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 @@
1import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";1import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";
2import type { WsRpcClient } from "@t3tools/client-runtime";
32
4import { resetVcsStatusStateForTests } from "./lib/vcsStatusState";
5import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState";
6import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";3import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
7import { resetServerStateForTests } from "./rpc/serverState";
8import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";
9import {
10 resetSavedEnvironmentRegistryStoreForTests,
11 resetSavedEnvironmentRuntimeStoreForTests,
12} from "./environments/runtime";
13import {
14 getPrimaryEnvironmentConnection,
15 resetEnvironmentServiceForTests,
16} from "./environments/runtime";
17import { getPrimaryKnownEnvironment } from "./environments/primary";
18import { showContextMenuFallback } from "./contextMenuFallback";4import { showContextMenuFallback } from "./contextMenuFallback";
19import {5import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage";
20 readBrowserClientSettings,
21 readBrowserSavedEnvironmentRegistry,
22 readBrowserSavedEnvironmentSecret,
23 removeBrowserSavedEnvironmentSecret,
24 writeBrowserClientSettings,
25 writeBrowserSavedEnvironmentRegistry,
26 writeBrowserSavedEnvironmentSecret,
27} from "./clientPersistenceStorage";
286
29let cachedApi: LocalApi | undefined;7let cachedApi: LocalApi | undefined;
308
...@@ -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}
3412
35function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi {13function 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 rpcClient70 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 rpcClient74 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 rpcClient78 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}
16982
170export function createLocalApi(rpcClient: WsRpcClient): LocalApi {83export function createLocalApi(): LocalApi {
171 return createBrowserLocalApi(rpcClient);84 return createBrowserLocalApi();
172}85}
17386
174export function readLocalApi(): LocalApi | undefined {87export 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 }
18295
183 const primaryEnvironment = getPrimaryKnownEnvironment();96 cachedApi = createBrowserLocalApi();
184 cachedApi = primaryEnvironment
185 ? createLocalApi(getPrimaryEnvironmentConnection().client)
186 : createBrowserLocalApi();
187 return cachedApi;97 return cachedApi;
188}98}
18999
...@@ -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 @@
1import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";1import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";3import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
3import type { UnifiedSettings } from "@t3tools/contracts/settings";4import type { UnifiedSettings } from "@t3tools/contracts/settings";
4import { normalizeProjectPathForComparison } from "./lib/projectPaths";5import { normalizeProjectPathForComparison } from "./lib/projectPaths";
5import type { Project } from "./types";
66
7export interface ProjectGroupingSettings {7export 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}
3434
35function deriveRepositoryRelativeProjectPath(35function 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 }
4242
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}
6565
66export function derivePhysicalProjectKey(project: Pick<Project, "environmentId" | "cwd">): string {66export 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}
6971
70export function deriveProjectGroupingOverrideKey(72export 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}
7577
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` and79// Must stay aligned with the drag handlers and readers in `Sidebar`.
78// the drag handlers in `Sidebar` so readers and writers agree.80export function getProjectOrderKey(
79export 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}
8285
83export function resolveProjectGroupingMode(86export 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}
9295
93function deriveRepositoryScopedKey(96function 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}
115118
116export function deriveLogicalProjectKey(119export 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}
133139
134export function deriveLogicalProjectKeyFromSettings(140export 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(
142151
143export function deriveLogicalProjectKeyFromRef(152export 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}
152164
153export function deriveProjectGroupLabel(input: {165export 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 }
170182
171 return input.representative.name;183 return input.representative.title;
172}184}
apps/web/src/modelPickerOpenState.ts deleted-17
...@@ -1,17 +0,0 @@
1import { create } from "zustand";
2
3const 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
11export function useModelPickerOpen(): boolean {
12 return useModelPickerOpenStore((store) => store.open);
13}
14
15export function setModelPickerOpen(open: boolean): void {
16 useModelPickerOpenStore.getState().setOpen(open);
17}
apps/web/src/modelPickerVisibility.ts created+13
...@@ -0,0 +1,13 @@
1const 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 */
8export 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";
6import { FetchHttpClient, HttpClient } from "effect/unstable/http";6import { FetchHttpClient, HttpClient } from "effect/unstable/http";
7import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";7import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
88
9import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
9import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary";10import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary";
10import { isElectron } from "../env";11import { isElectron } from "../env";
11import { APP_VERSION } from "~/branding";12import { 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());
8081
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 ),
9192 );
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);
10295
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}
111114
112async function disposeTracerRuntime(115async function disposeTracerRuntime(
...@@ -117,12 +120,8 @@ async function disposeTracerRuntime(...@@ -117,12 +120,8 @@ async function disposeTracerRuntime(
117 return;120 return;
118 }121 }
119122
120 await runtime123 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}
127126
128function formatError(error: unknown): string {127function formatError(error: unknown): string {
apps/web/src/portDiscoveryState.ts+9-44
...@@ -1,55 +1,20 @@...@@ -1,55 +1,20 @@
1import type {1import type { DiscoveredLocalServer, EnvironmentId, ThreadId } from "@t3tools/contracts";
2 DiscoveredLocalServer,
3 EnvironmentApi,
4 EnvironmentId,
5 ThreadId,
6} from "@t3tools/contracts";
7import { useMemo } from "react";2import { useMemo } from "react";
8import { create } from "zustand";
93
10const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]);4import { previewEnvironment } from "./state/preview";
115import { useEnvironmentQuery } from "./state/query";
12interface 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}
186
19export const usePortDiscoveryStore = create<PortDiscoveryState>((set) => ({7const 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
37export 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}
468
47export function useDiscoveredPorts(9export 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}
5419
55export function useThreadDiscoveredPorts(input: {20export function useThreadDiscoveredPorts(input: {
apps/web/src/previewStateStore.test.ts+74-64
...@@ -1,11 +1,24 @@...@@ -1,11 +1,24 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts";2import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vite-plus/test";3import { beforeEach, describe, expect, it } from "vite-plus/test";
44
5import { __testing, selectThreadPreviewState, usePreviewStateStore } from "./previewStateStore";5import {
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";
618
7const environmentId = "env-1" as EnvironmentId;19const environmentId = "env-1" as EnvironmentId;
8const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1"));20const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1"));
21const otherRef = scopeThreadRef(environmentId, ThreadId.make("thread-2"));
922
10const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewSessionSnapshot => ({23const 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});
1932
20beforeEach(() => {33beforeEach(() => {
21 usePreviewStateStore.setState({ byThreadKey: {} });34 resetPreviewStateForTests();
22});35});
2336
24describe("previewStateStore (single-tab)", () => {37describe("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 });
6083
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)", () => {
87109
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 });
111132
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 });
135155
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);
166184
167 store.removeSession(ref, second.tabId);185 removePreviewSession(ref, second.tabId);
168186
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)", () => {
174192
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);
180197
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 });
187204
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 });
192209
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 });
212228
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);
249263
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)", () => {
255269
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 });
264277
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 );
281293
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 });
289301
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)", () => {
301312
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, replayed5 * 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 */
14import { scopedThreadKey } from "@t3tools/client-runtime";8import { useAtomValue } from "@effect/atom-react";
9import { scopedThreadKey } from "@t3tools/client-runtime/environment";
15import {10import {
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";
20import { create } from "zustand";15import { Atom } from "effect/unstable/reactivity";
2116
22import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants";17import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants";
18import { appAtomRegistry } from "./rpc/atomRegistry";
2319
24export interface DesktopPreviewOverlay {20export 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}
4236
...@@ -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});
5145
52const revisionByThreadKey = new Map<string, number>();46const emptyPreviewStateAtom = Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe(
47 Atom.withLabel("preview:empty-thread"),
48);
5349
54const bumpPreviewStateRevision = (threadKey: string): void => {50export 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);
5756
58export 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.
59interface ActivePreviewThreadIndex {
60 readonly keys: ReadonlySet<string>;
60}61}
6162
62export interface PreviewStateStoreState {63const 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}
7666
77const ensureState = (67const 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"));
8177
82const updateThread = (78const 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};
9279
93const removeThreadKey = (80function 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
91function 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}
101106
102const dedupeRecentUrls = (existing: string[], url: string): string[] => {107const 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};
127132
128export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({133export 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);138export 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":142export 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"146export function subscribeThreadPreviewState(
142 ? current.recentlySeenUrls147 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 };159export 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) => ({208export 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
241export 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}
279257
280export function selectThreadPreviewState(258export 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;262export 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
275export 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
283export 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}
287289
288export function isPreviewSupportedInRuntime(): boolean {290export 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}
292294
295export 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
293export const __testing = {303export 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}
5858
59export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null {59export 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 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { type EnvironmentId, ThreadId } from "@t3tools/contracts";2import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vite-plus/test";3import { beforeEach, describe, expect, it } from "vite-plus/test";
44
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 at7 * 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 */
10import { scopedThreadKey } from "@t3tools/client-runtime";10import { scopedThreadKey } from "@t3tools/client-runtime/environment";
11import type { ScopedThreadRef } from "@t3tools/contracts";11import type { ScopedThreadRef } from "@t3tools/contracts";
12import { create } from "zustand";12import { create } from "zustand";
13import { createJSONStorage, persist } from "zustand/middleware";13import { createJSONStorage, persist } from "zustand/middleware";
apps/web/src/router.ts+2-12
...@@ -1,25 +1,15 @@...@@ -1,25 +1,15 @@
1import { createElement } from "react";1import { createElement } from "react";
2import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3import { createRouter, RouterHistory } from "@tanstack/react-router";2import { createRouter, RouterHistory } from "@tanstack/react-router";
43
5import { AppAtomRegistryProvider } from "./rpc/atomRegistry";4import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
6import { routeTree } from "./routeTree.gen";5import { routeTree } from "./routeTree.gen";
76
8export function getRouter(history: RouterHistory) {7export 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}
2515
apps/web/src/routes/__root.tsx+112-166
...@@ -1,14 +1,14 @@...@@ -1,14 +1,14 @@
1import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";1import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
2import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";2import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
3import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3import {4import {
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";
10import { useEffect, useEffectEvent, useRef } from "react";11import { useEffect, useEffectEvent, useRef, useState } from "react";
11import { QueryClient, useQueryClient } from "@tanstack/react-query";
1212
13import { APP_DISPLAY_NAME } from "../branding";13import { APP_DISPLAY_NAME } from "../branding";
14import { AppSidebarLayout } from "../components/AppSidebarLayout";14import { AppSidebarLayout } from "../components/AppSidebarLayout";
...@@ -16,11 +16,7 @@ import { CommandPalette } from "../components/CommandPalette";...@@ -16,11 +16,7 @@ import { CommandPalette } from "../components/CommandPalette";
16import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";16import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
17import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";17import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
18import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";18import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
19import {19import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
20 SlowRpcAckToastCoordinator,
21 WebSocketConnectionCoordinator,
22 WebSocketConnectionSurface,
23} from "../components/WebSocketConnectionSurface";
24import { Button } from "../components/ui/button";20import { Button } from "../components/ui/button";
25import {21import {
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";
31import { resolveAndPersistPreferredEditor } from "../editorPreferences";27import { resolveAndPersistPreferredEditor } from "../editorPreferences";
32import { readLocalApi } from "../localApi";
33import { useSettings } from "../hooks/useSettings";28import { useSettings } from "../hooks/useSettings";
34import {29import {
35 deriveLogicalProjectKeyFromSettings,30 deriveLogicalProjectKeyFromSettings,
36 derivePhysicalProjectKeyFromPath,31 derivePhysicalProjectKeyFromPath,
37 selectProjectGroupingSettings,32 selectProjectGroupingSettings,
38} from "../logicalProject";33} from "../logicalProject";
39import {
40 getServerConfigUpdatedNotification,
41 ServerConfigUpdatedNotification,
42 startServerStateSync,
43 useServerConfig,
44 useServerConfigUpdatedSubscription,
45 useServerWelcomeSubscription,
46} from "../rpc/serverState";
47import { useStore } from "../store";
48import { useUiStateStore } from "../uiStateStore";34import { useUiStateStore } from "../uiStateStore";
49import { syncBrowserChromeTheme } from "../hooks/useTheme";35import { syncBrowserChromeTheme } from "../hooks/useTheme";
50import {
51 ensureEnvironmentConnectionBootstrapped,
52 getPrimaryEnvironmentConnection,
53 listSavedEnvironmentRecords,
54 waitForSavedEnvironmentRegistryHydration,
55 startEnvironmentConnectionService,
56 useSavedEnvironmentRegistryStore,
57} from "../environments/runtime";
58import { configureClientTracing } from "../observability/clientTracing";36import { configureClientTracing } from "../observability/clientTracing";
59import {37import { resolveInitialServerAuthGateState } from "../environments/primary";
60 ensurePrimaryEnvironmentReady,
61 getPrimaryKnownEnvironment,
62 resolveInitialServerAuthGateState,
63 updatePrimaryEnvironmentDescriptor,
64} from "../environments/primary";
65import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";38import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";
39import { shellEnvironment } from "../state/shell";
40import { useAtomValue } from "@effect/atom-react";
41import { useAtomCommand } from "../state/use-atom-command";
42import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
43import {
44 primaryServerConfigAtom,
45 primaryServerConfigEventAtom,
46 primaryServerWelcomeAtom,
47} from "../state/server";
48import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities";
49import {
50 createKeybindingsUpdateToastController,
51 type KeybindingsUpdateToastController,
52} from "../components/KeybindingsUpdateToast.logic";
6653
67export const Route = createRootRouteWithContext<{54export 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 }
7863
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 }
8771
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}
155129
156function HostedStaticEnvironmentBootstrap() {130function HostedStaticEnvironmentBootstrap() {
157 const savedEnvironmentCount = useSavedEnvironmentRegistryStore(131 const { environments } = useEnvironments();
158 (state) => Object.keys(state.byId).length,132 const activeEnvironmentId = useActiveEnvironmentId();
159 );
160133
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 }
165142
166 const currentActiveEnvironmentId = useStore.getState().activeEnvironmentId;143 if (activeEnvironmentId) {
167 if (currentActiveEnvironmentId) {
168 return;144 return;
169 }145 }
170146
171 const firstSavedEnvironment = listSavedEnvironmentRecords()[0];147 const firstSavedEnvironment = environments[0];
172 if (!firstSavedEnvironment) {148 if (!firstSavedEnvironment) {
173 return;149 return;
174 }150 }
175151
176 useStore.getState().setActiveEnvironmentId(firstSavedEnvironment.environmentId);152 setActiveEnvironmentId(firstSavedEnvironment.environmentId);
177 }, [savedEnvironmentCount]);153 }, [activeEnvironmentId, environments]);
178154
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}
252228
253function ServerStateBootstrap() {
254 useEffect(() => {
255 if (!getPrimaryKnownEnvironment()) {
256 return;
257 }
258
259 return startServerStateSync(getPrimaryEnvironmentConnection().client.server);
260 }, []);
261
262 return null;
263}
264
265function AuthenticatedTracingBootstrap() {229function 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}
272236
273function EnvironmentConnectionManagerBootstrap() {
274 const queryClient = useQueryClient();
275
276 useEffect(() => {
277 return startEnvironmentConnectionService(queryClient);
278 }, [queryClient]);
279
280 return null;
281}
282
283function EventRouter() {237function 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 );
294254
295 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {255 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {
296 if (!payload) return;256 if (!payload) return;
297257
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 (bootstrapProject267 (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 });
342295
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) {
346299 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 }
355301
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 }
370310
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 }
384339 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 });
409354
410 useEffect(() => {355 useEffect(() => {
411 if (!serverConfig) {356 if (!serverConfig) {
412 return;357 return;
413 }358 }
414359
415 updatePrimaryEnvironmentDescriptor(serverConfig.environment);
416 setActiveEnvironmentId(serverConfig.environment.environmentId);360 setActiveEnvironmentId(serverConfig.environment.environmentId);
417 }, [serverConfig, setActiveEnvironmentId]);361 }, [serverConfig]);
418362
419 useEffect(() => {363 useEffect(() => {
420 disposedRef.current = false;364 handleWelcome(serverWelcome);
421 return () => {365 }, [serverWelcome]);
422 disposedRef.current = true;
423 };
424 }, []);
425366
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]);
428374
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 @@
1import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";1import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";
2import { useEffect, useMemo } from "react";2import { useEffect } from "react";
33
4import ChatView from "../components/ChatView";4import ChatView from "../components/ChatView";
5import { threadHasStarted } from "../components/ChatView.logic";5import { threadHasStarted } from "../components/ChatView.logic";
6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
7import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch";7import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch";
8import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";
9import { createThreadSelectorByRef } from "../storeSelectors";
10import { resolveThreadRouteRef } from "../threadRoutes";8import { resolveThreadRouteRef } from "../threadRoutes";
11import { SidebarInset } from "~/components/ui/sidebar";9import { SidebarInset } from "~/components/ui/sidebar";
10import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities";
11import { useEnvironmentQuery } from "../state/query";
12import { environmentShell } from "../state/shell";
1213
13function ChatThreadRouteView() {14function 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;
3943
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]);
4653
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]);
5160
52 if (!threadRef || !bootstrapComplete || !routeThreadExists) return null;61 if (!threadRef || !bootstrapComplete || !routeThreadExists) {
62 return null;
63 }
5364
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 @@
1import { createFileRoute, useNavigate } from "@tanstack/react-router";1import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect, useMemo } from "react";2import { useEffect } from "react";
3import ChatView from "../components/ChatView";3import ChatView from "../components/ChatView";
4import { threadHasStarted } from "../components/ChatView.logic";4import { threadHasStarted } from "../components/ChatView.logic";
5import { useComposerDraftStore, DraftId } from "../composerDraftStore";5import {
6 DraftId,
7 markPromotedDraftThreadByRef,
8 useComposerDraftStore,
9} from "../composerDraftStore";
6import { SidebarInset } from "../components/ui/sidebar";10import { SidebarInset } from "../components/ui/sidebar";
7import { createThreadSelectorAcrossEnvironments } from "../storeSelectors";
8import { useStore } from "../store";
9import { buildThreadRouteParams } from "../threadRoutes";11import { buildThreadRouteParams } from "../threadRoutes";
12import { useThread, useThreadRefs } from "../state/entities";
1013
11function DraftChatThreadRouteView() {14function 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?.promotedTo32 useEffect(() => {
26 ? serverThreadStarted33 if (!inferredThreadRef || draftSession?.promotedTo) {
27 ? draftSession.promotedTo34 return;
28 : null35 }
29 : serverThread36 markPromotedDraftThreadByRef(inferredThreadRef);
30 ? {37 }, [draftSession?.promotedTo, inferredThreadRef]);
31 environmentId: serverThread.environmentId,
32 threadId: serverThread.id,
33 }
34 : null,
35 [draftSession?.promotedTo, serverThread, serverThreadStarted],
36 );
3738
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";
5import { Button } from "../components/ui/button";5import { Button } from "../components/ui/button";
6import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";6import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
7import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";7import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";
8import { useSavedEnvironmentRegistryStore } from "../environments/runtime";8import { useEnvironments } from "../state/environments";
9import { APP_DISPLAY_NAME } from "~/branding";9import { APP_DISPLAY_NAME } from "~/branding";
10import { hasCloudPublicConfig } from "~/cloud/publicConfig";10import { hasCloudPublicConfig } from "~/cloud/publicConfig";
1111
12function ChatIndexRouteView() {12function 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 );
1715
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 }
2119
apps/web/src/routes/_chat.tsx+7-6
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";1import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";
2import { useEffect } from "react";3import { useEffect } from "react";
34
4import { useCommandPaletteStore } from "../commandPaletteStore";5import { isCommandPaletteOpen } from "../commandPaletteContext";
5import { dispatchPreviewAction } from "../components/preview/previewActionBus";6import { dispatchPreviewAction } from "../components/preview/previewActionBus";
6import { useHandleNewThread } from "../hooks/useHandleNewThread";7import { useHandleNewThread } from "../hooks/useHandleNewThread";
7import {8import {
...@@ -18,14 +19,14 @@ import { useThreadSelectionStore } from "../threadSelectionStore";...@@ -18,14 +19,14 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
18import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";19import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
19import { stackedThreadToast, toastManager } from "~/components/ui/toast";20import { stackedThreadToast, toastManager } from "~/components/ui/toast";
20import { useSettings } from "~/hooks/useSettings";21import { useSettings } from "~/hooks/useSettings";
21import { useServerKeybindings } from "~/rpc/serverState";22import { primaryServerKeybindingsAtom } from "~/state/server";
2223
23function ChatRouteGlobalShortcuts() {24function 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 routeThreadRef31 routeThreadRef
31 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen32 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
...@@ -53,7 +54,7 @@ function ChatRouteGlobalShortcuts() {...@@ -53,7 +54,7 @@ function ChatRouteGlobalShortcuts() {
53 },54 },
54 });55 });
5556
56 if (useCommandPaletteStore.getState().open) {57 if (isCommandPaletteOpen()) {
57 return;58 return;
58 }59 }
5960
...@@ -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 @@
1import {
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";
13import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
14import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
15
16import {
17 getServerConfig,
18 getServerKeybindings,
19 onProvidersUpdated,
20 onServerConfigUpdated,
21 onWelcome,
22 resetServerStateForTests,
23 startServerStateSync,
24} from "./serverState";
25
26function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
27 listeners.add(listener);
28 return () => {
29 listeners.delete(listener);
30 };
31}
32
33function 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
42const lifecycleListeners = new Set<(event: ServerLifecycleStreamEvent) => void>();
43const configListeners = new Set<(event: ServerConfigStreamEvent) => void>();
44
45const 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
61const 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
74const 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
97const 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
107function emitLifecycleEvent(event: ServerLifecycleStreamEvent) {
108 for (const listener of lifecycleListeners) {
109 listener(event);
110 }
111}
112
113function emitServerConfigEvent(event: ServerConfigStreamEvent) {
114 for (const listener of configListeners) {
115 listener(event);
116 }
117}
118
119async 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
134beforeEach(() => {
135 vi.clearAllMocks();
136 lifecycleListeners.clear();
137 configListeners.clear();
138 resetServerStateForTests();
139});
140
141afterEach(() => {
142 resetServerStateForTests();
143});
144
145describe("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 @@
1import { useAtomSubscribe, useAtomValue } from "@effect/atom-react";
2import {
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";
13import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
14import { Atom } from "effect/unstable/reactivity";
15import { useCallback, useRef } from "react";
16
17import type { WsRpcClient } from "@t3tools/client-runtime";
18import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry";
19
20export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"];
21
22export interface ServerConfigUpdatedNotification {
23 readonly id: number;
24 readonly payload: ServerConfigUpdatedPayload;
25 readonly source: ServerConfigUpdateSource;
26}
27
28type ServerStateClient = Pick<
29 WsRpcClient["server"],
30 "getConfig" | "subscribeConfig" | "subscribeLifecycle"
31>;
32
33function makeStateAtom<A>(label: string, initialValue: A) {
34 return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label));
35}
36
37function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdatedPayload {
38 return {
39 issues: config.issues,
40 providers: config.providers,
41 settings: config.settings,
42 };
43}
44
45const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = [];
46const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
47
48const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray<EditorId> =>
49 config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
50const selectKeybindings = (config: ServerConfig | null) =>
51 config?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
52const selectKeybindingsConfigPath = (config: ServerConfig | null) =>
53 config?.keybindingsConfigPath ?? null;
54const selectObservability = (config: ServerConfig | null) => config?.observability ?? null;
55const selectProviders = (config: ServerConfig | null) =>
56 config?.providers ?? EMPTY_SERVER_PROVIDERS;
57const selectSettings = (config: ServerConfig | null): ServerSettings =>
58 config?.settings ?? DEFAULT_SERVER_SETTINGS;
59
60export const welcomeAtom = makeStateAtom<ServerLifecycleWelcomePayload | null>(
61 "server-welcome",
62 null,
63);
64export const serverConfigAtom = makeStateAtom<ServerConfig | null>("server-config", null);
65export const serverConfigUpdatedAtom = makeStateAtom<ServerConfigUpdatedNotification | null>(
66 "server-config-updated",
67 null,
68);
69export const providersUpdatedAtom = makeStateAtom<ServerProviderUpdatedPayload | null>(
70 "server-providers-updated",
71 null,
72);
73
74export function getServerConfig(): ServerConfig | null {
75 return appAtomRegistry.get(serverConfigAtom);
76}
77
78export function getServerKeybindings(): ServerConfig["keybindings"] {
79 return selectKeybindings(getServerConfig());
80}
81
82export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null {
83 return appAtomRegistry.get(serverConfigUpdatedAtom);
84}
85
86export function setServerConfigSnapshot(config: ServerConfig): void {
87 resolveServerConfig(config);
88 emitProvidersUpdated({ providers: config.providers });
89 emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot");
90}
91
92export 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
123export 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
139export 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
153export function emitWelcome(payload: ServerLifecycleWelcomePayload): void {
154 appAtomRegistry.set(welcomeAtom, payload);
155}
156
157export function onWelcome(listener: (payload: ServerLifecycleWelcomePayload) => void): () => void {
158 return subscribeLatest(welcomeAtom, listener);
159}
160
161export 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
169export function onProvidersUpdated(
170 listener: (payload: ServerProviderUpdatedPayload) => void,
171): () => void {
172 return subscribeLatest(providersUpdatedAtom, listener);
173}
174
175export 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
208export function resetServerStateForTests() {
209 resetAppAtomRegistryForTests();
210 nextServerConfigUpdatedNotificationId = 1;
211}
212
213let nextServerConfigUpdatedNotificationId = 1;
214
215function resolveServerConfig(config: ServerConfig): void {
216 appAtomRegistry.set(serverConfigAtom, config);
217}
218
219function emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void {
220 appAtomRegistry.set(providersUpdatedAtom, payload);
221}
222
223function emitServerConfigUpdated(
224 payload: ServerConfigUpdatedPayload,
225 source: ServerConfigUpdateSource,
226): void {
227 appAtomRegistry.set(serverConfigUpdatedAtom, {
228 id: nextServerConfigUpdatedNotificationId++,
229 payload,
230 source,
231 });
232}
233
234function 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
250function 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
267export function useServerConfig(): ServerConfig | null {
268 return useAtomValue(serverConfigAtom);
269}
270
271export function useServerSettings(): ServerSettings {
272 return useAtomValue(serverConfigAtom, selectSettings);
273}
274
275export function useServerProviders(): ReadonlyArray<ServerProvider> {
276 return useAtomValue(serverConfigAtom, selectProviders);
277}
278
279export function useServerKeybindings(): ServerConfig["keybindings"] {
280 return useAtomValue(serverConfigAtom, selectKeybindings);
281}
282
283export function useServerAvailableEditors(): ReadonlyArray<EditorId> {
284 return useAtomValue(serverConfigAtom, selectAvailableEditors);
285}
286
287export function useServerKeybindingsConfigPath(): string | null {
288 return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath);
289}
290
291export function useServerObservability(): ServerConfig["observability"] | null {
292 return useAtomValue(serverConfigAtom, selectObservability);
293}
294
295export function useServerWelcomeSubscription(
296 listener: (payload: ServerLifecycleWelcomePayload) => void,
297): void {
298 useLatestAtomSubscription(welcomeAtom, listener);
299}
300
301export 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 @@
1export {1export {
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 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
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
16describe("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 @@
1import { useAtomValue } from "@effect/atom-react";
2import { DEFAULT_RECONNECT_BACKOFF, getReconnectDelayMs } from "@t3tools/client-runtime";
3import { Atom } from "effect/unstable/reactivity";
4
5import { appAtomRegistry } from "./atomRegistry";
6
7export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting";
8export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting";
9
10export const WS_RECONNECT_INITIAL_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.initialDelayMs;
11export const WS_RECONNECT_BACKOFF_FACTOR = DEFAULT_RECONNECT_BACKOFF.backoffFactor;
12export const WS_RECONNECT_MAX_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.maxDelayMs;
13export const WS_RECONNECT_MAX_RETRIES = DEFAULT_RECONNECT_BACKOFF.maxRetries!;
14export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1;
15
16export 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
35const 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
54export const wsConnectionStatusAtom = Atom.make(INITIAL_WS_CONNECTION_STATUS).pipe(
55 Atom.keepAlive,
56 Atom.withLabel("ws-connection-status"),
57);
58
59function isoNow() {
60 return new Date().toISOString();
61}
62
63function updateWsConnectionStatus(
64 updater: (current: WsConnectionStatus) => WsConnectionStatus,
65): WsConnectionStatus {
66 const nextStatus = updater(getWsConnectionStatus());
67 appAtomRegistry.set(wsConnectionStatusAtom, nextStatus);
68 return nextStatus;
69}
70
71export interface WsConnectionMetadata {
72 readonly connectionLabel?: string | null;
73 readonly versionMismatchHint?: string | null;
74}
75
76function normalizeConnectionLabel(label: string | null | undefined): string | null {
77 const normalized = label?.trim();
78 return normalized ? normalized : null;
79}
80
81export function getWsConnectionStatus(): WsConnectionStatus {
82 return appAtomRegistry.get(wsConnectionStatusAtom);
83}
84
85export 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
101export 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
118export 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
135function 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
144export 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
158export 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
180export function setBrowserOnlineStatus(online: boolean): WsConnectionStatus {
181 return updateWsConnectionStatus((current) => ({
182 ...current,
183 online,
184 }));
185}
186
187export function resetWsReconnectBackoff(): WsConnectionStatus {
188 return updateWsConnectionStatus((current) => ({
189 ...current,
190 nextRetryAt: null,
191 reconnectAttemptCount: 0,
192 reconnectPhase: "idle",
193 }));
194}
195
196export function resetWsConnectionStateForTests(): void {
197 appAtomRegistry.set(wsConnectionStatusAtom, INITIAL_WS_CONNECTION_STATUS);
198}
199
200export function useWsConnectionStatus(): WsConnectionStatus {
201 return useAtomValue(wsConnectionStatusAtom);
202}
203
204export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null {
205 return getReconnectDelayMs(retryIndex);
206}
207
208function 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 @@
1import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 __resetClientTracingForTests,
7 configureClientTracing,
8} from "../observability/clientTracing";
9import {
10 getSlowRpcAckRequests,
11 resetRequestLatencyStateForTests,
12 setSlowRpcAckThresholdMsForTests,
13} from "../rpc/requestLatencyState";
14import {
15 getWsConnectionStatus,
16 getWsConnectionUiState,
17 resetWsConnectionStateForTests,
18} from "../rpc/wsConnectionState";
19import { WsTransport } from "./wsTransport";
20
21const encodeServerSettings = Schema.encodeSync(ServerSettings);
22
23type WsEventType = "open" | "message" | "close" | "error";
24type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
25type WsListener = (event?: WsEvent) => void;
26
27const sockets: MockWebSocket[] = [];
28
29class 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
86const originalWebSocket = globalThis.WebSocket;
87const originalFetch = globalThis.fetch;
88const transports: WsTransport[] = [];
89
90function 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
98async 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
113function createTransport(...args: ConstructorParameters<typeof WsTransport>): WsTransport {
114 const transport = new WsTransport(...args);
115 transports.push(transport);
116 return transport;
117}
118
119beforeEach(() => {
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
146afterEach(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
157describe("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 @@
1import {
2 WsTransport as BaseWsTransport,
3 type WsProtocolLifecycleHandlers,
4 type WsRpcProtocolSocketUrlProvider,
5 type WsTransportOptions,
6} from "@t3tools/client-runtime";
7import { createWsRpcProtocolLayer as createSharedWsRpcProtocolLayer } from "@t3tools/client-runtime";
8
9import { ClientTracingLive } from "../observability/clientTracing";
10import {
11 acknowledgeRpcRequest,
12 clearAllTrackedRpcRequests,
13 trackRpcRequestSent,
14} from "./requestLatencyState";
15import {
16 recordWsConnectionAttempt,
17 recordWsConnectionClosed,
18 recordWsConnectionErrored,
19 recordWsConnectionOpened,
20} from "./wsConnectionState";
21
22function 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
50const webWsTransportOptions = {
51 tracingLayer: ClientTracingLive,
52 createProtocolLayer: createWsRpcProtocolLayer,
53 onBeforeReconnect: () => clearAllTrackedRpcRequests(),
54} satisfies WsTransportOptions;
55
56export 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}
290290
291type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">;291type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">;
292type SessionActivityState = Pick<ThreadSession, "orchestrationStatus" | "activeTurnId">;292type SessionActivityState = Pick<NonNullable<Thread["session"]>, "status" | "activeTurnId">;
293293
294export function isLatestTurnSettled(294export 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}
304304
...@@ -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}
13401339
1341export function deriveTimelineEntries(1340export 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}
13681367
1369export function inferCheckpointTurnCountByTurnId(1368export 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}
13811380
1382export function derivePhase(session: ThreadSession | null): SessionPhase {1381export 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";
22
3import {3import {
4 areShortcutModifierStatesEqual,4 areShortcutModifierStatesEqual,
5 clearShortcutModifierState,5 shortcutModifierStateAfterKeyboardEvent,
6 readShortcutModifierState,6 type ShortcutModifierState,
7 setShortcutModifierState,
8 syncShortcutModifierStateFromKeyboardEvent,
9} from "./shortcutModifierState";7} from "./shortcutModifierState";
108
9const emptyState = (): ShortcutModifierState => ({
10 metaKey: false,
11 ctrlKey: false,
12 altKey: false,
13 shiftKey: false,
14});
15
11function keyboardEventLike(type: "keydown" | "keyup", init: Partial<KeyboardEvent>): KeyboardEvent {16function 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 });
3843
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();
4146 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 });
8852
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();
9155 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 });
10468
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 });
11883
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 });
13298
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 @@
1import { create } from "zustand";1import { useEffect, useState } from "react";
22
3export interface ShortcutModifierState {3export 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}
2828
29const 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
45export function useShortcutModifierState(): ShortcutModifierState {29export 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}
4856
49function normalizeModifierKey(key: string): keyof ShortcutModifierState | null {57function 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}
6674
67export function syncShortcutModifierStateFromKeyboardEvent(event: KeyboardEvent): void {75export 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 }
7794
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
86export function setShortcutModifierState(state: ShortcutModifierState): void {
87 useShortcutModifierStateStore.getState().setState(state);
88}
89
90export function clearShortcutModifierState(): void {
91 useShortcutModifierStateStore.getState().clear();
92}
93
94export 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 @@
1import { scopeProjectRef } from "@t3tools/client-runtime";1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts";2import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts";
3import {3import {
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 @@
1import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
apps/web/src/state/auth.ts created+5
...@@ -0,0 +1,5 @@
1import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime);
apps/web/src/state/desktopNetworkAccess.test.ts created+50
...@@ -0,0 +1,50 @@
1import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import { AtomRegistry } from "effect/unstable/reactivity";
4import { describe, expect, it, vi } from "vite-plus/test";
5
6import { createDesktopNetworkAccessStateAtom } from "./desktopNetworkAccess";
7
8const 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
16const advertisedEndpoints: ReadonlyArray<AdvertisedEndpoint> = [];
17
18describe("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 @@
1import type {
2 AdvertisedEndpoint,
3 DesktopBridge,
4 DesktopServerExposureState,
5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as Schema from "effect/Schema";
8import { Atom } from "effect/unstable/reactivity";
9
10import { appAtomRegistry } from "~/rpc/atomRegistry";
11
12const DESKTOP_NETWORK_ACCESS_STALE_TIME_MS = 30_000;
13
14type DesktopNetworkAccessBridge = Pick<
15 DesktopBridge,
16 "getAdvertisedEndpoints" | "getServerExposureState"
17>;
18
19export interface DesktopNetworkAccessSnapshot {
20 readonly advertisedEndpoints: ReadonlyArray<AdvertisedEndpoint>;
21 readonly serverExposureState: DesktopServerExposureState;
22}
23
24class DesktopNetworkAccessError extends Schema.TaggedErrorClass<DesktopNetworkAccessError>()(
25 "DesktopNetworkAccessError",
26 {
27 message: Schema.String,
28 cause: Schema.optional(Schema.Defect()),
29 },
30) {}
31
32function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined {
33 return typeof window === "undefined" ? undefined : window.desktopBridge;
34}
35
36export 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
73export const desktopNetworkAccessStateAtom = createDesktopNetworkAccessStateAtom(
74 getDesktopNetworkAccessBridge,
75);
76
77export function refreshDesktopNetworkAccessState(): void {
78 appAtomRegistry.refresh(desktopNetworkAccessStateAtom);
79}
apps/web/src/state/desktopSshHosts.test.ts created+41
...@@ -0,0 +1,41 @@
1import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import { describe, expect, it, vi } from "vite-plus/test";
5
6import { createDesktopSshHostsStateAtom } from "./desktopSshHosts";
7
8const hosts: ReadonlyArray<DesktopDiscoveredSshHost> = [
9 {
10 alias: "devbox",
11 hostname: "devbox.local",
12 port: null,
13 source: "ssh-config",
14 username: null,
15 },
16];
17
18describe("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 @@
1import type { DesktopBridge, DesktopDiscoveredSshHost } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import { Atom } from "effect/unstable/reactivity";
5
6type DesktopSshDiscoveryBridge = Pick<DesktopBridge, "discoverSshHosts">;
7
8class DesktopSshDiscoveryError extends Schema.TaggedErrorClass<DesktopSshDiscoveryError>()(
9 "DesktopSshDiscoveryError",
10 {
11 message: Schema.String,
12 cause: Schema.optional(Schema.Defect()),
13 },
14) {}
15
16function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined {
17 return typeof window === "undefined" ? undefined : window.desktopBridge;
18}
19
20export 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
47export const desktopSshHostsStateAtom = createDesktopSshHostsStateAtom(
48 getDesktopSshDiscoveryBridge,
49);
apps/web/src/state/desktopUpdate.test.ts created+111
...@@ -0,0 +1,111 @@
1import type { DesktopUpdateState } from "@t3tools/contracts";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import { AtomRegistry } from "effect/unstable/reactivity";
4import { describe, expect, it, vi } from "vite-plus/test";
5
6import { createDesktopUpdateStateAtom } from "./desktopUpdate";
7
8const 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
25describe("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 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Queue from "effect/Queue";
5import * as Stream from "effect/Stream";
6import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
7import { Atom } from "effect/unstable/reactivity";
8
9type DesktopUpdateBridge = Pick<DesktopBridge, "getUpdateState" | "onUpdateState">;
10
11function getDesktopUpdateBridge(): DesktopUpdateBridge | undefined {
12 return typeof window === "undefined" ? undefined : window.desktopBridge;
13}
14
15export 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
53const desktopUpdateStateAtom = createDesktopUpdateStateAtom(getDesktopUpdateBridge);
54
55export function useDesktopUpdateState(): DesktopUpdateState | null {
56 return AsyncResult.getOrElse(useAtomValue(desktopUpdateStateAtom), () => null);
57}
apps/web/src/state/entities.ts created+197
...@@ -0,0 +1,197 @@
1import { useAtomValue } from "@effect/atom-react";
2import type {
3 EnvironmentProject,
4 EnvironmentThread,
5 EnvironmentThreadShell,
6} from "@t3tools/client-runtime/state/shell";
7import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads";
8import type {
9 OrchestrationMessage,
10 OrchestrationProposedPlan,
11 OrchestrationSession,
12 OrchestrationThreadActivity,
13 ScopedProjectRef,
14 ScopedThreadRef,
15} from "@t3tools/contracts";
16import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
17import { Atom } from "effect/unstable/reactivity";
18import { useMemo } from "react";
19import { appAtomRegistry } from "../rpc/atomRegistry";
20import { environmentProjects } from "./projects";
21import { environmentThreadDetails, environmentThreadShells } from "./threads";
22
23const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);
24const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
25const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]);
26const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]);
27const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]);
28
29const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
30 Atom.withLabel("web-project:empty"),
31);
32const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe(
33 Atom.withLabel("web-project-refs:empty"),
34);
35const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe(
36 Atom.withLabel("web-thread-refs:empty"),
37);
38const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).pipe(
39 Atom.withLabel("web-thread-shell:empty"),
40);
41const EMPTY_THREAD_DETAIL_ATOM = Atom.make<EnvironmentThread | null>(null).pipe(
42 Atom.withLabel("web-thread-detail:empty"),
43);
44const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe(
45 Atom.withLabel("web-thread-messages:empty"),
46);
47const EMPTY_ACTIVITIES_ATOM = Atom.make(EMPTY_ACTIVITIES).pipe(
48 Atom.withLabel("web-thread-activities:empty"),
49);
50const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe(
51 Atom.withLabel("web-thread-proposed-plans:empty"),
52);
53const EMPTY_SESSION_ATOM = Atom.make<OrchestrationSession | null>(null).pipe(
54 Atom.withLabel("web-thread-session:empty"),
55);
56
57export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe(
58 Atom.keepAlive,
59 Atom.withLabel("web-active-environment-id"),
60);
61
62export function useActiveEnvironmentId(): EnvironmentId | null {
63 return useAtomValue(activeEnvironmentIdAtom);
64}
65
66export function readActiveEnvironmentId(): EnvironmentId | null {
67 return appAtomRegistry.get(activeEnvironmentIdAtom);
68}
69
70export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void {
71 appAtomRegistry.set(activeEnvironmentIdAtom, environmentId);
72}
73
74export function useProjectRefs(): ReadonlyArray<ScopedProjectRef> {
75 return useAtomValue(environmentProjects.projectRefsAtom);
76}
77
78export function useThreadRefs(): ReadonlyArray<ScopedThreadRef> {
79 return useAtomValue(environmentThreadShells.threadRefsAtom);
80}
81
82export 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
92export 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
102export function useProjects(): ReadonlyArray<EnvironmentProject> {
103 return useAtomValue(environmentProjects.projectsAtom);
104}
105
106export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
107 return useAtomValue(environmentThreadShells.threadShellsAtom);
108}
109
110export function useThreadShellsForProjectRefs(
111 refs: ReadonlyArray<ScopedProjectRef>,
112): ReadonlyArray<EnvironmentThreadShell> {
113 return useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs));
114}
115
116export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null {
117 return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref));
118}
119
120export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null {
121 return useAtomValue(
122 ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref),
123 );
124}
125
126export 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. */
133export 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
139export function useThreadMessages(
140 ref: ScopedThreadRef | null,
141): ReadonlyArray<OrchestrationMessage> {
142 return useAtomValue(
143 ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref),
144 );
145}
146
147export function useThreadActivities(
148 ref: ScopedThreadRef | null,
149): ReadonlyArray<OrchestrationThreadActivity> {
150 return useAtomValue(
151 ref === null ? EMPTY_ACTIVITIES_ATOM : environmentThreadDetails.activitiesAtom(ref),
152 );
153}
154
155export 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
163export function useThreadSession(ref: ScopedThreadRef | null): OrchestrationSession | null {
164 return useAtomValue(
165 ref === null ? EMPTY_SESSION_ATOM : environmentThreadDetails.sessionAtom(ref),
166 );
167}
168
169export function readProject(ref: ScopedProjectRef): EnvironmentProject | null {
170 return appAtomRegistry.get(environmentProjects.projectAtom(ref));
171}
172
173export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null {
174 return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));
175}
176
177export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {
178 return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref));
179}
180
181export function readEnvironmentThreadRefs(
182 environmentId: EnvironmentId,
183): ReadonlyArray<ScopedThreadRef> {
184 return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId));
185}
186
187export function readThreadRefs(): ReadonlyArray<ScopedThreadRef> {
188 return appAtomRegistry.get(environmentThreadShells.threadRefsAtom);
189}
190
191export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 connectionCatalogDisplayUrl,
4 type EnvironmentPresentation as BaseEnvironmentPresentation,
5} from "@t3tools/client-runtime/connection";
6import type { EnvironmentId } from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import { Atom } from "effect/unstable/reactivity";
9import { useMemo } from "react";
10
11import { environmentCatalog } from "../connection/catalog";
12import { environmentPresentations, useEnvironmentPresentation } from "./presentation";
13import { useEnvironmentQuery } from "./query";
14import { relayEnvironmentDiscovery } from "./relay";
15import { usePreparedConnection } from "./session";
16
17export interface EnvironmentPresentation extends BaseEnvironmentPresentation {
18 readonly environmentId: EnvironmentId;
19 readonly label: string;
20 readonly displayUrl: string | null;
21 readonly relayManaged: boolean;
22}
23
24export 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
33function 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
46export 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
67export function usePrimaryEnvironmentId(): EnvironmentId | null {
68 return useAtomValue(primaryEnvironmentIdAtom);
69}
70
71export 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
84export function usePrimaryEnvironment(): EnvironmentPresentation | null {
85 return useEnvironment(usePrimaryEnvironmentId());
86}
87
88export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): string | null {
89 const prepared = usePreparedConnection(environmentId);
90 return Option.isSome(prepared) ? prepared.value.httpBaseUrl : null;
91}
92
93export function useRelayEnvironmentDiscovery() {
94 return useAtomValue(relayEnvironmentDiscovery.stateValueAtom);
95}
96
97export function useEnvironmentConnectionState(environmentId: EnvironmentId) {
98 return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId));
99}
apps/web/src/state/filesystem.ts created+5
...@@ -0,0 +1,5 @@
1import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime);
apps/web/src/state/git.ts created+5
...@@ -0,0 +1,5 @@
1import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime);
apps/web/src/state/orchestration.ts created+5
...@@ -0,0 +1,5 @@
1import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime);
apps/web/src/state/presentation.ts created+31
...@@ -0,0 +1,31 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection";
3import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation";
4import type { EnvironmentId } from "@t3tools/contracts";
5import { Atom } from "effect/unstable/reactivity";
6
7import { environmentCatalog } from "../connection/catalog";
8import { environmentSession } from "./session";
9
10export const environmentPresentations = createEnvironmentPresentationAtoms({
11 catalogValueAtom: environmentCatalog.catalogValueAtom,
12 stateAtom: environmentCatalog.stateAtom,
13 configValueAtom: environmentSession.configValueAtom,
14});
15
16const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make<EnvironmentPresentation | null>(null).pipe(
17 Atom.withLabel("web-environment-presentation:empty"),
18);
19
20export 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 @@
1import { createPreviewEnvironmentAtoms } from "@t3tools/client-runtime/state/preview";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime);
apps/web/src/state/projects.ts created+12
...@@ -0,0 +1,12 @@
1import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects";
2import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects";
3
4import { environmentCatalog } from "../connection/catalog";
5import { connectionAtomRuntime } from "../connection/runtime";
6import { environmentSnapshotAtom } from "./shell";
7
8export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime);
9export const environmentProjects = createEnvironmentProjectAtoms({
10 catalogValueAtom: environmentCatalog.catalogValueAtom,
11 snapshotAtom: environmentSnapshotAtom,
12});
apps/web/src/state/queries.ts created+257
...@@ -0,0 +1,257 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type CheckpointDiffTarget,
4 type ComposerPathSearchTarget,
5} from "@t3tools/client-runtime/state/threads";
6import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
7import type {
8 EnvironmentId,
9 OrchestrationThread,
10 ThreadId,
11 VcsListRefsResult,
12 VcsRef,
13} from "@t3tools/contracts";
14import * as Cause from "effect/Cause";
15import * as Option from "effect/Option";
16import { AsyncResult, Atom } from "effect/unstable/reactivity";
17import { useCallback, useEffect, useMemo, useState } from "react";
18
19import { appAtomRegistry } from "../rpc/atomRegistry";
20import { orchestrationEnvironment } from "./orchestration";
21import { projectEnvironment } from "./projects";
22import { useEnvironmentQuery } from "./query";
23import { useEnvironmentThread } from "./threads";
24import { vcsEnvironment } from "./vcs";
25
26const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120;
27const COMPOSER_PATH_SEARCH_LIMIT = 80;
28const VCS_REF_LIST_LIMIT = 100;
29const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
30const INITIAL_BRANCH_CURSORS = [undefined] as const;
31
32export interface ThreadDetailView {
33 readonly data: OrchestrationThread | null;
34 readonly error: string | null;
35 readonly isPending: boolean;
36 readonly isDeleted: boolean;
37}
38
39function 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
54export 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
67export 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
83export 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
184export 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
217export 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 @@
1import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
2import * as Cause from "effect/Cause";
3import * as Option from "effect/Option";
4import { AsyncResult, Atom } from "effect/unstable/reactivity";
5
6const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
7 Atom.withLabel("web-environment-query:empty"),
8);
9
10export interface EnvironmentQueryView<A> {
11 readonly data: A | null;
12 readonly error: string | null;
13 readonly isPending: boolean;
14 readonly refresh: () => void;
15}
16
17function 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
24export 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 @@
1import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/state/relay";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const relayEnvironmentDiscovery =
6 createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime);
apps/web/src/state/review.ts created+5
...@@ -0,0 +1,5 @@
1import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime);
apps/web/src/state/server.ts created+100
...@@ -0,0 +1,100 @@
1import {
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";
10import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";
11import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";
12import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
13import * as Option from "effect/Option";
14import { AsyncResult, Atom } from "effect/unstable/reactivity";
15
16import { environmentCatalog } from "../connection/catalog";
17import { connectionAtomRuntime } from "../connection/runtime";
18import { primaryEnvironmentIdAtom } from "./environments";
19import { environmentSession } from "./session";
20
21export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
22 initialConfigValueAtom: environmentSession.configValueAtom,
23});
24export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
25 catalogValueAtom: environmentCatalog.catalogValueAtom,
26 configValueAtom: serverEnvironment.configValueAtom,
27});
28
29interface PrimaryServerState {
30 readonly config: ServerConfig | null;
31 readonly latestEvent: ServerConfigStreamEvent | null;
32 readonly welcome: ServerLifecycleWelcomePayload | null;
33}
34
35const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = [];
36const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
37const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = {
38 config: null,
39 latestEvent: null,
40 welcome: null,
41};
42
43export 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
62export const primaryServerConfigAtom = Atom.make(
63 (get): ServerConfig | null => get(primaryServerStateAtom).config,
64).pipe(Atom.withLabel("web-primary-server-config"));
65
66export const primaryServerConfigEventAtom = Atom.make(
67 (get): ServerConfigStreamEvent | null => get(primaryServerStateAtom).latestEvent,
68).pipe(Atom.withLabel("web-primary-server-config-event"));
69
70export const primaryServerWelcomeAtom = Atom.make(
71 (get): ServerLifecycleWelcomePayload | null => get(primaryServerStateAtom).welcome,
72).pipe(Atom.withLabel("web-primary-server-welcome"));
73
74export const primaryServerSettingsAtom = Atom.make(
75 (get): ServerSettings => get(primaryServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS,
76).pipe(Atom.withLabel("web-primary-server-settings"));
77
78export const primaryServerProvidersAtom = Atom.make(
79 (get): ReadonlyArray<ServerProvider> =>
80 get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS,
81).pipe(Atom.withLabel("web-primary-server-providers"));
82
83export const primaryServerKeybindingsAtom = Atom.make(
84 (get): ServerConfig["keybindings"] =>
85 get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS,
86).pipe(Atom.withLabel("web-primary-server-keybindings"));
87
88export 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
93export const primaryServerKeybindingsConfigPathAtom = Atom.make(
94 (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null,
95).pipe(Atom.withLabel("web-primary-server-keybindings-config-path"));
96
97export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
3import type { EnvironmentId } from "@t3tools/contracts";
4import * as Option from "effect/Option";
5import { Atom } from "effect/unstable/reactivity";
6
7import { connectionAtomRuntime } from "../connection/runtime";
8import { appAtomRegistry } from "../rpc/atomRegistry";
9
10export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime);
11
12const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe(
13 Atom.withLabel("web-prepared-connection:empty"),
14);
15
16export function usePreparedConnection(environmentId: EnvironmentId | null) {
17 return useAtomValue(
18 environmentId === null
19 ? EMPTY_PREPARED_CONNECTION_ATOM
20 : environmentSession.preparedConnectionValueAtom(environmentId),
21 );
22}
23
24export 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 @@
1import {
2 createEnvironmentShellAtoms,
3 createEnvironmentShellSummaryAtom,
4 createEnvironmentSnapshotAtom,
5 createShellEnvironmentAtoms,
6} from "@t3tools/client-runtime/state/shell";
7
8import { environmentCatalog } from "../connection/catalog";
9import { connectionAtomRuntime } from "../connection/runtime";
10
11export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime);
12export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime);
13export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom);
14export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({
15 catalogValueAtom: environmentCatalog.catalogValueAtom,
16 shellStateValueAtom: environmentShell.stateValueAtom,
17});
apps/web/src/state/sourceControl.ts created+5
...@@ -0,0 +1,5 @@
1import { createSourceControlEnvironmentAtoms } from "@t3tools/client-runtime/state/source-control";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const sourceControlEnvironment = createSourceControlEnvironmentAtoms(connectionAtomRuntime);
apps/web/src/state/sourceControlActions.ts created+356
...@@ -0,0 +1,356 @@
1import { useAtomValue } from "@effect/atom-react";
2import type {
3 AtomCommandFailure,
4 AtomCommandResult,
5 AtomCommandSuccess,
6} from "@t3tools/client-runtime/state/runtime";
7import {
8 VcsActionUnavailableError,
9 type VcsActionOperation,
10} from "@t3tools/client-runtime/state/vcs";
11import type {
12 EnvironmentId,
13 GitActionProgressEvent,
14 GitResolvePullRequestResult,
15 GitStackedAction,
16 SourceControlCloneProtocol,
17 SourceControlRepositoryVisibility,
18 ThreadId,
19} from "@t3tools/contracts";
20import * as Cause from "effect/Cause";
21import * as Option from "effect/Option";
22import { AsyncResult } from "effect/unstable/reactivity";
23import { useCallback } from "react";
24
25import { appAtomRegistry } from "../rpc/atomRegistry";
26import { gitEnvironment } from "./git";
27import { useEnvironmentQuery } from "./query";
28import { sourceControlEnvironment } from "./sourceControl";
29import { useAtomCommand } from "./use-atom-command";
30import { vcsActionManager, vcsEnvironment } from "./vcs";
31
32export type SourceControlActionKind =
33 | "init"
34 | "pull"
35 | "publishRepository"
36 | "runStackedAction"
37 | "preparePullRequestThread";
38
39export interface SourceControlActionScope {
40 readonly environmentId: EnvironmentId | null;
41 readonly cwd: string | null;
42}
43
44interface 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
58const 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
66function 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
119function 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
129function unavailableResult(message: string) {
130 return AsyncResult.failure<never, VcsActionUnavailableError>(
131 Cause.fail(new VcsActionUnavailableError({ message })),
132 );
133}
134
135export 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
147export 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
162export 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
191export 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
238export 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
281export 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
311export interface PullRequestResolutionTarget {
312 readonly environmentId: EnvironmentId | null;
313 readonly cwd: string | null;
314 readonly reference: string | null;
315}
316
317export 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
335export 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 @@
1import { createTerminalEnvironmentAtoms } from "@t3tools/client-runtime/state/terminal";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const terminalEnvironment = createTerminalEnvironmentAtoms(connectionAtomRuntime);
apps/web/src/state/terminalSessions.ts created+90
...@@ -0,0 +1,90 @@
1import {
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";
9import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts";
10import { useMemo } from "react";
11
12import { useEnvironmentQuery } from "./query";
13import { terminalEnvironment } from "./terminal";
14
15export 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
51export 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
85export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 createEnvironmentThreadDetailAtoms,
4 createEnvironmentThreadShellAtoms,
5 createEnvironmentThreadStateAtoms,
6 EMPTY_ENVIRONMENT_THREAD_STATE,
7 type EnvironmentThreadState,
8 createThreadEnvironmentAtoms,
9} from "@t3tools/client-runtime/state/threads";
10import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
11import * as Option from "effect/Option";
12import { AsyncResult, Atom } from "effect/unstable/reactivity";
13
14import { environmentCatalog } from "../connection/catalog";
15import { connectionAtomRuntime } from "../connection/runtime";
16import { environmentSnapshotAtom } from "./shell";
17
18export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime);
19export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime);
20export const environmentThreadDetails = createEnvironmentThreadDetailAtoms(
21 environmentThreads.stateAtom,
22);
23export const environmentThreadShells = createEnvironmentThreadShellAtoms({
24 catalogValueAtom: environmentCatalog.catalogValueAtom,
25 snapshotAtom: environmentSnapshotAtom,
26});
27
28const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe(
29 Atom.withLabel("web-environment-thread:empty"),
30);
31
32export 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 @@
1import { RegistryContext } from "@effect/atom-react";
2import {
3 type AtomCommand,
4 type AtomCommandOptions,
5 type AtomCommandResult,
6 runAtomCommand,
7} from "@t3tools/client-runtime/state/runtime";
8import { useCallback, useContext } from "react";
9
10export 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 @@
1import { RegistryContext } from "@effect/atom-react";
2import {
3 executeAtomQuery,
4 type AtomCommandOptions,
5 type AtomCommandResult,
6} from "@t3tools/client-runtime/state/runtime";
7import { AsyncResult, type Atom } from "effect/unstable/reactivity";
8import { useCallback, useContext } from "react";
9
10export 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 @@
1import {
2 createVcsActionManager,
3 createVcsEnvironmentAtoms,
4} from "@t3tools/client-runtime/state/vcs";
5
6import { connectionAtomRuntime } from "../connection/runtime";
7
8export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime);
9export const vcsActionManager = createVcsActionManager(connectionAtomRuntime);
apps/web/src/store.test.ts deleted-1083
...@@ -1,1083 +0,0 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";
2import {
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";
14import { describe, expect, it } from "vite-plus/test";
15
16import {
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";
29import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
30
31const localEnvironmentId = EnvironmentId.make("environment-local");
32const remoteEnvironmentId = EnvironmentId.make("environment-remote");
33
34function 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
62function 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
90function 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
184function 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
207function localEnvironmentStateOf(state: AppState): EnvironmentState {
208 return selectEnvironmentState(state, localEnvironmentId);
209}
210
211function environmentStateOf(state: AppState, environmentId: EnvironmentId): EnvironmentState {
212 return selectEnvironmentState(state, environmentId);
213}
214
215function projectsOf(state: AppState) {
216 return selectProjectsAcrossEnvironments(state);
217}
218
219function threadsOf(state: AppState) {
220 return selectThreadsAcrossEnvironments(state);
221}
222
223function 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
250describe("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
283describe("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
405describe("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
445describe("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 @@
1import 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";
21import { isProviderDriverKind, ProviderDriverKind } from "@t3tools/contracts";
22import type { ThreadId, TurnId } from "@t3tools/contracts";
23import * as Schema from "effect/Schema";
24import { resolveModelSlugForProvider } from "@t3tools/shared/model";
25import { create } from "zustand";
26import {
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";
37import { sanitizeThreadErrorMessage } from "./rpc/transportError";
38import { getThreadFromEnvironmentState } from "./threadDerivation";
39const isProviderDriverKindValue = Schema.is(ProviderDriverKind);
40
41export 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
98export interface AppState {
99 activeEnvironmentId: EnvironmentId | null;
100 environmentStateById: Record<string, EnvironmentState>;
101}
102
103const 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
123const initialState: AppState = {
124 activeEnvironmentId: null,
125 environmentStateById: {},
126};
127
128const MAX_THREAD_MESSAGES = 2_000;
129const MAX_THREAD_CHECKPOINTS = 500;
130const MAX_THREAD_PROPOSED_PLANS = 200;
131const MAX_THREAD_ACTIVITIES = 500;
132const EMPTY_THREAD_IDS: ThreadId[] = [];
133
134function 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.
141function 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
151function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>): Project["scripts"] {
152 return scripts.map((script) => ({ ...script }));
153}
154
155function 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
168function 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
189function 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
201function 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
213function 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
234function 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
260function 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
316function 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
335function toThreadTurnState(thread: Thread): ThreadTurnState {
336 return {
337 latestTurn: thread.latestTurn,
338 ...(thread.pendingSourceProposedPlan
339 ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan }
340 : {}),
341 };
342}
343
344function 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
353function 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
370function 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
387function 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
411function 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
431function 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
439function appendId<T extends string>(ids: readonly T[], id: T): T[] {
440 return ids.includes(id) ? [...ids] : [...ids, id];
441}
442
443function removeId<T extends string>(ids: readonly T[], id: T): T[] {
444 return ids.filter((value) => value !== id);
445}
446
447function 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
459function 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
471function 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
483function 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
495function getProjects(state: EnvironmentState): Project[] {
496 return state.projectIds.flatMap((projectId) => {
497 const project = state.projectById[projectId];
498 return project ? [project] : [];
499 });
500}
501
502function 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 */
515function 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 */
574function 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 */
695function 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
765function 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
776function 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
831function 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
841function 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
858function 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 */
888function 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
906function 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
925function 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
996function 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
1005function 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
1014function 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
1033function toLegacyProvider(providerName: string | null): ProviderDriverKind {
1034 if (isProviderDriverKindValue(providerName)) {
1035 return providerName;
1036 }
1037 return ProviderDriverKind.make("codex");
1038}
1039
1040function 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
1056function 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
1067function getStoredEnvironmentState(
1068 state: AppState,
1069 environmentId: EnvironmentId,
1070): EnvironmentState {
1071 return state.environmentStateById[environmentId] ?? initialEnvironmentState;
1072}
1073
1074function 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
1098function 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
1138export 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
1157export 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
1174function 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
1701function 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
1762export 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
1778function 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
1786export function selectEnvironmentState(
1787 state: AppState,
1788 environmentId: EnvironmentId | null | undefined,
1789): EnvironmentState {
1790 return environmentId ? getStoredEnvironmentState(state, environmentId) : initialEnvironmentState;
1791}
1792
1793export function selectProjectsForEnvironment(
1794 state: AppState,
1795 environmentId: EnvironmentId | null | undefined,
1796): Project[] {
1797 return getProjects(selectEnvironmentState(state, environmentId));
1798}
1799
1800export function selectThreadsForEnvironment(
1801 state: AppState,
1802 environmentId: EnvironmentId | null | undefined,
1803): Thread[] {
1804 return getThreads(selectEnvironmentState(state, environmentId));
1805}
1806
1807export function selectProjectsAcrossEnvironments(state: AppState): Project[] {
1808 return getEnvironmentEntries(state).flatMap(([, environmentState]) =>
1809 getProjects(environmentState),
1810 );
1811}
1812
1813export 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). */
1820export 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
1829export 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
1838export 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
1854export 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
1863export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean {
1864 return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete;
1865}
1866
1867export 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
1876export 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
1885export 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
1894export 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
1903export 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
1913export 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
1929export 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
1945export 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
1961export 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
1972export 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
1986export 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
2009interface 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
2031export 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 @@
1import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
2import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store";
3import { type Project, type Thread } from "./types";
4import { getThreadFromEnvironmentState } from "./threadDerivation";
5
6export 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
13function 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
42export function createThreadSelectorByRef(
43 ref: ScopedThreadRef | null | undefined,
44): (state: AppState) => Thread | undefined {
45 return createScopedThreadSelector(() => ref);
46}
47
48export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
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";
16import type {
17 EnvironmentId,
18 TerminalAttachInput,
19 TerminalSessionSnapshot,
20 ThreadId,
21} from "@t3tools/contracts";
22
23import { appAtomRegistry } from "./rpc/atomRegistry";
24
25export const terminalSessionManager = createTerminalSessionManager({
26 getRegistry: () => appAtomRegistry,
27});
28
29export function subscribeTerminalMetadata(input: {
30 readonly environmentId: EnvironmentId;
31 readonly client: Parameters<typeof terminalSessionManager.subscribeMetadata>[0]["client"];
32}) {
33 return terminalSessionManager.subscribeMetadata(input);
34}
35
36export 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
52export 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
59export 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
69export 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 @@
1import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime";1import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
2import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vite-plus/test";3import { beforeEach, describe, expect, it } from "vite-plus/test";
44
...@@ -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 });
2324
...@@ -261,6 +262,29 @@ describe("terminalUiStateStore actions", () => {...@@ -261,6 +262,29 @@ describe("terminalUiStateStore actions", () => {
261 ]);262 ]);
262 });263 });
263264
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 */
77
8import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime";8import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment";
9import { type ScopedThreadRef } from "@t3tools/contracts";9import { type ScopedThreadRef } from "@t3tools/contracts";
10import { create } from "zustand";10import { create } from "zustand";
11import { createJSONStorage, persist } from "zustand/middleware";11import { createJSONStorage, persist } from "zustand/middleware";
...@@ -519,8 +519,51 @@ function updateTerminalUiStateByThreadKey(...@@ -519,8 +519,51 @@ function updateTerminalUiStateByThreadKey(
519 };519 };
520}520}
521521
522function 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
555function 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
522interface TerminalUiStateStoreState {563interface 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 {
541584
542export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(585export 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 };
563624
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 @@
1import type { MessageId, ThreadId, TurnId } from "@t3tools/contracts";
2import type { EnvironmentState } from "./store";
3import type {
4 ChatMessage,
5 ProposedPlan,
6 Thread,
7 ThreadSession,
8 ThreadShell,
9 ThreadTurnState,
10 TurnDiffSummary,
11} from "./types";
12
13const EMPTY_MESSAGES: ChatMessage[] = [];
14const EMPTY_ACTIVITIES: Thread["activities"] = [];
15const EMPTY_PROPOSED_PLANS: ProposedPlan[] = [];
16const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = [];
17const EMPTY_MESSAGE_MAP: Record<MessageId, ChatMessage> = {};
18const EMPTY_ACTIVITY_MAP: Record<string, Thread["activities"][number]> = {};
19const EMPTY_PROPOSED_PLAN_MAP: Record<string, ProposedPlan> = {};
20const EMPTY_TURN_DIFF_MAP: Record<TurnId, TurnDiffSummary> = {};
21
22const collectedByIdsCache = new WeakMap<readonly string[], WeakMap<object, readonly unknown[]>>();
23const 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
36function 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
63function 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
71function 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
79function 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
90function 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
101export 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 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { scopeThreadRef } from "@t3tools/client-runtime";2import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { ThreadId } from "@t3tools/contracts";3import { ThreadId } from "@t3tools/contracts";
4import { DraftId } from "./composerDraftStore";4import { DraftId } from "./composerDraftStore";
55
apps/web/src/threadRoutes.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";2import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
3import type { DraftId } from "./composerDraftStore";3import type { DraftId } from "./composerDraftStore";
44
apps/web/src/types.ts+23-137
...@@ -1,22 +1,20 @@...@@ -1,22 +1,20 @@
1import type {1import 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";
13import type {
14 EnvironmentProject,
15 EnvironmentThread,
16 EnvironmentThreadShell,
17} from "@t3tools/client-runtime/state/shell";
2018
21export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";19export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
22export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";20export 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}
3533
36export interface ChatImageAttachment {34export 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}
4437
45export type ChatAttachment = ChatImageAttachment;38export type ChatAttachment = ChatImageAttachment;
4639
47export interface ChatMessage {40export 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}
5743
58export interface ProposedPlan {44export type ProposedPlan = OrchestrationProposedPlan;
59 id: OrchestrationProposedPlanId;45export type TurnDiffFileChange = OrchestrationCheckpointFile;
60 turnId: TurnId | null;46export type TurnDiffSummary = OrchestrationCheckpointSummary;
61 planMarkdown: string;
62 implementedAt: string | null;
63 implementationThreadId: ThreadId | null;
64 createdAt: string;
65 updatedAt: string;
66}
6747
68export interface TurnDiffFileChange {48export type Project = EnvironmentProject;
69 path: string;49export type Thread = EnvironmentThread;
70 kind?: string | undefined;50export type ThreadShell = EnvironmentThreadShell;
71 additions?: number | undefined;
72 deletions?: number | undefined;
73}
74
75export 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
85export 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
97export 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
121export 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}
13751
138export interface ThreadTurnState {52export interface ThreadTurnState {
139 latestTurn: OrchestrationLatestTurn | null;53 latestTurn: OrchestrationLatestTurn | null;
140 pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"];
141}54}
14255
143export interface SidebarThreadSummary {56export type SidebarThreadSummary = EnvironmentThreadShell;
144 id: ThreadId;57export 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
162export 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";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
33
4import {4import {
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";
2019
...@@ -30,418 +29,187 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {...@@ -30,418 +29,187 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
30}29}
3130
32describe("uiStateStore pure functions", () => {31describe("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");
3636
37 const next = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z");37 expect(visited.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z");
3838 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 });
5441
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 });
6349
64 const next = markThreadUnread(initialState, threadId, latestTurnCompletedAt);50 const next = markThreadUnread(initialState, threadId, "2026-02-25T12:30:00.000Z");
6551
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 });
6855
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 });
7659
77 const next = markThreadUnread(initialState, threadId, null);60 expect(resolveProjectExpanded({ logical: false, [physicalKey]: true }, ["logical"])).toBe(
7861 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 });
8169
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 });
8973
90 const next = reorderProjects(initialState, [project1], [project3]);74 const next = setProjectExpanded(initialState, keys, false);
9175
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 });
9483
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();
10989
110 const next = setDefaultAdvertisedEndpointKey(initialState, "desktop-core:lan:http");90 const next = reorderProjects(makeUiState(), currentOrder, [project1], [project3]);
11191
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 });
11894
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 });
141101
142 const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);102 const next = reorderProjects(makeUiState(), currentOrder, [keyALocal, keyARemote], [keyC]);
143103
144 expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);104 expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);
145 });105 });
146106
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]);
158110
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 });
161118
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 });
175122
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 });
184128 expect(
185 const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyBLocal, keyBRemote]);129 setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true)
186130 .threadChangedFilesExpandedById,
187 // Target members may become non-contiguous; this is fine because the131 ).toEqual({});
188 // sidebar groups by logical key using first-occurrence positioning.
189 expect(next.projectOrder).toEqual([keyBLocal, keyALocal, keyARemote, keyBRemote]);
190 });132 });
191133
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]);
200136
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);
203139 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});
213144
214 it("syncProjects preserves current project order during snapshot recovery", () => {145describe("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 });
384165
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 });
418182
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"],
422186 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 });
431197
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 });
441202
442 const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true);203 expect(
443204 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});
447215
...@@ -465,146 +233,77 @@ function createLocalStorageStub(): Storage {...@@ -465,146 +233,77 @@ function createLocalStorageStub(): Storage {
465 };233 };
466}234}
467235
468describe("uiStateStore persistence round-trip", () => {236describe("uiStateStore persistence", () => {
469 let localStorageStub: Storage;237 let localStorageStub: Storage;
470238
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 });
478244
479 afterEach(() => {245 afterEach(() => {
480 vi.unstubAllGlobals();246 vi.unstubAllGlobals();
481 });247 });
482248
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`, so250 const state = makeUiState({
485 // an empty array on rehydrate was indistinguishable from a fresh install251 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"],
489255 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": {
494260 "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" };
511266
512 let state = syncProjects(makeUiState(), [projectA, projectB]);
513 state = setProjectExpanded(state, projectB.key, false);
514 persistState(state);267 persistState(state);
515268
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: {
521274 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",
528281 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 of283 "turn-1": false,
531 // collapsedProjectCwds opts the session into the legacy fallback so284 },
532 // upgrade users do not see previously collapsed rows pop open.285 },
533 hydratePersistedProjectState({
534 expandedProjectCwds: ["/projA"],
535 });286 });
536287 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,
541292 },
542 expect(rehydrated.projectExpandedById).toEqual({293 },
543 kA: true,
544 kB: false,
545 });294 });
546 });295 });
547296
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 });
581301
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);
597303
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 @@
1import { Debouncer } from "@tanstack/react-pacer";1import { Debouncer } from "@tanstack/react-pacer";
2import { create } from "zustand";2import { create } from "zustand";
3import { normalizeProjectPathForComparison } from "./lib/projectPaths";
34
4export const PERSISTED_STATE_KEY = "t3code:ui-state:v1";5export const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
5const LEGACY_PERSISTED_STATE_KEYS = [6const 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;
1718
18export interface PersistedUiState {19export 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 {
3943
40export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {}44export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {}
4145
42export 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
50export interface SyncThreadInput {
51 key: string;
52 seedVisitedAt?: string | undefined;
53}
54
55const initialState: UiState = {46const 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};
6253
63const persistedCollapsedProjectCwds = new Set<string>();54const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:";
64const persistedExpandedProjectCwds = new Set<string>();55const LEGACY_PROJECT_EXPANSION_DEFAULT_KEY = "legacy-project-expansion-default";
65const persistedProjectOrderCwds: string[] = [];
66const 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.
71let persistedProjectStateUsesLegacyShape = false;
72const currentProjectCwdById = new Map<string, string>();
73const currentProjectCwdsByLogicalKey = new Map<string, string[]>();
74const currentLogicalKeyByPhysicalKey = new Map<string, string>();
75let legacyKeysCleanedUp = false;56let legacyKeysCleanedUp = false;
7657
58export function legacyProjectCwdPreferenceKey(cwd: string): string {
59 return `${LEGACY_PROJECT_CWD_PREFERENCE_PREFIX}${normalizeProjectPathForComparison(cwd)}`;
60}
61
62function 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
73function 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
84function 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
99export 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
77function readPersistedState(): UiState {138function 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}
139187
140export 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
164export function persistState(state: UiState): void {188export 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 unambiguously193 const projectExpandedById = Object.fromEntries(
170 // means "first install" rather than "user collapsed everything"; without194 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 {
210226
211const debouncedPersistState = new Debouncer(persistState, { wait: 500 });227const debouncedPersistState = new Debouncer(persistState, { wait: 500 });
212228
213function recordsEqual<T>(left: Record<string, T>, right: Record<string, T>): boolean {229export 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
227function 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
233function 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
250export 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
409export 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
446export 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}
491276
492export 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
509export function setThreadChangedFilesExpanded(277export 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}
568336
569export function toggleProject(state: UiState, projectId: string): UiState {337export 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}
579349
580export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState {350export 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}
592369
593export function reorderProjects(370export 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 }
606384
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 }
611389
612 const projectOrder = [...state.projectOrder];390 const projectOrder = [...currentProjectOrder];
613391
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}
635413
636interface UiStateStore extends UiState {414interface 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 {
651426
652export const useUiStateStore = create<UiStateStore>((set) => ({427export 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}));
671444
672useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state));445useUiStateStore.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 @@
1import type { Thread } from "./types";1import type { ThreadShell } from "./types";
22
3function normalizeWorktreePath(path: string | null): string | null {3function 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}
1010
11export function getOrphanedWorktreePathForThread(11export 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 @@
1import {
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";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import { HttpRouter, HttpServer } from "effect/unstable/http";
16import * as HttpApi from "effect/unstable/httpapi/HttpApi";
17import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
18import { http } from "msw";
19
20const BrowserEnvironmentHttpApi = HttpApi.make("browserEnvironment")
21 .add(EnvironmentMetadataHttpApi)
22 .add(EnvironmentAuthHttpApi);
23
24const TEST_SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-05-01T12:00:00.000Z");
25const 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
38const unexpectedEndpoint = (endpoint: string) =>
39 Effect.die(new Error(`Unexpected browser environment HTTP endpoint: ${endpoint}`));
40
41export 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 @@
1import { ORCHESTRATION_WS_METHODS, WS_METHODS, WsRpcGroup } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as PubSub from "effect/PubSub";
5import * as Scope from "effect/Scope";
6import * as Stream from "effect/Stream";
7import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc";
8
9type RpcServerInstance = RpcServer.RpcServer<any>;
10
11type BrowserWsClient = {
12 send: (data: string) => void;
13};
14
15export type NormalizedWsRpcRequestBody = {
16 _tag: string;
17 [key: string]: unknown;
18};
19
20type UnaryResolverResult = unknown | Promise<unknown>;
21
22interface BrowserWsRpcHarnessOptions {
23 readonly resolveUnary?: (request: NormalizedWsRpcRequestBody) => UnaryResolverResult;
24 readonly getInitialStreamValues?: (
25 request: NormalizedWsRpcRequestBody,
26 ) => ReadonlyArray<unknown> | undefined;
27}
28
29const 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
42const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys());
43
44function 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
54function asEffect(result: UnaryResolverResult): Effect.Effect<unknown> {
55 if (result instanceof Promise) {
56 return Effect.promise(() => result);
57 }
58 return Effect.succeed(result);
59}
60
61export 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";
2import react, { reactCompilerPreset } from "@vitejs/plugin-react";2import react, { reactCompilerPreset } from "@vitejs/plugin-react";
3import babel from "@rolldown/plugin-babel";3import babel from "@rolldown/plugin-babel";
4import { tanstackRouter } from "@tanstack/router-plugin/vite";4import { tanstackRouter } from "@tanstack/router-plugin/vite";
5import { playwright } from "vite-plus/test/browser-playwright";
6import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config";5import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config";
7import "vite-plus/test/config";6import "vite-plus/test/config";
8import { defineConfig } from "vite-plus";7import { defineConfig } from "vite-plus";
...@@ -59,30 +58,6 @@ const unitTestProject = {...@@ -59,30 +58,6 @@ const unitTestProject = {
59 },58 },
60} satisfies TestProjectInlineConfiguration;59} satisfies TestProjectInlineConfiguration;
6160
62const 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
86function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined {61function 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
3The connection runtime is shared by web and mobile. It owns connectivity,
4authentication, retries, transport lifetime, cached environment data, and
5environment-scoped operations.
6
7Web and mobile mount this runtime once at the application root. There is no
8legacy connection owner or supported mixed mode.
9
10## Ownership
11
12Each registered environment has one scoped Effect `Context` containing focused
13services:
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`
27owns its scope. There is no aggregate environment runtime facade. React
28components do not create connections, transports, retry loops, or RPC clients.
29
30## Connection State
31
32The supervisor is the only retry owner.
33
341. A persisted or platform registration marks an environment as desired.
352. If the device is offline, the supervisor releases the active session and
36 waits without consuming retry attempts.
373. When online, the supervisor asks the broker for one prepared connection and
38 asks the session factory for one RPC session.
394. Transient failures retry forever with exponential backoff capped at 16
40 seconds.
415. Connectivity changes, application activation, credential changes, and
42 explicit user retry interrupt the current wait and trigger a fresh attempt.
436. Authentication or configuration failures remain blocked until an external
44 wakeup changes the relevant input.
457. An involuntary session close keeps the registration and cache, then retries.
468. Explicit removal closes the session and deletes the registration,
47 credentials, shell cache, and thread cache.
48
49The UI derives `available`, `offline`, `connecting`, `reconnecting`,
50`connected`, and `error` from supervisor state plus explicit data-sync state.
51It does not infer connection health from cached data or the existence of a
52transport object. An environment becomes `connected` after the socket opens and
53the initial config RPC succeeds, proving that the server is responsive. Shell
54and thread synchronization are independent data states. A healthy RPC
55transport with a failed shell subscription is shown as connected with a
56synchronization error, not as a reconnect that is not actually scheduled.
57
58## Data Boundary
59
60Finite 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
76The Promise bridge exists only at the React/Atom boundary. Runtime and business
77logic remain Effect-native.
78
79## Platform Layers
80
81Web 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
91Platform layers adapt operating-system capabilities. They do not implement
92connection policy.
93
94## Source Boundaries
95
96The 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
107Other reusable state lives in domain subpaths such as `shell`, `threads`,
108`terminal`, and `vcs`. Applications must import explicit package subpaths; the
109package intentionally has no root export.
110
111## Application Boundary
112
113The application root mounts the shared connection application layer, creates
114its own Atom runtime, and selects the domain atom factories required by that
115platform. Web and mobile may expose different hooks and features without
116changing connection ownership.
117
118Application code must not construct `WsTransport`, RPC clients, retry loops, or
119raw orchestration commands. Persistence paths belong to the platform
120registration and cache stores, with explicit migration or invalidation policy.
121
122## Verification
123
124Core state-machine tests use `@effect/vitest` and deterministic service layers.
125Required 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.
45Shared request and response schemas live in45Shared request and response schemas live in
46[`packages/contracts/src/relay.ts`](../../packages/contracts/src/relay.ts). Shared client-side relay46[`packages/contracts/src/relay.ts`](../../packages/contracts/src/relay.ts). Shared client-side relay
47calls live in47calls 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).
4949
50## Working Locally50## Working Locally
5151
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 });
9595
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 );
107109
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";
26const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt";26const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt";
27const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt";27const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt";
28const LINK_CHALLENGE_KIND = "environment_link_challenge";28const LINK_CHALLENGE_KIND = "environment_link_challenge";
29export const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes";
2930
30const LinkChallengeClaims = Schema.Struct({31const 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};
7374
75function 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
74function resolveDpopAccessTokenScopes(input: {86function 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";
8import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime";8import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
9import {9import {
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";
35import * as Option from "effect/Option";35import * as Option from "effect/Option";
36import * as Redacted from "effect/Redacted";36import * as Redacted from "effect/Redacted";
37import * as Schema from "effect/Schema";37import * as Schema from "effect/Schema";
38import { FetchHttpClient, HttpClient } from "effect/unstable/http";38import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http";
3939
40import * as EnvironmentLinks from "./EnvironmentLinks.ts";40import * as EnvironmentLinks from "./EnvironmentLinks.ts";
41import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";41import * 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}
181181
182function 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
195const currentTraceId = Effect.currentSpan.pipe(
196 Effect.map((span) => span.traceId),
197 Effect.orElseSucceed(() => "unavailable"),
198);
199
182const withoutRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>) =>200const 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" }));
184202
...@@ -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";
69import { RelayDb } from "../db.ts";69import { RelayDb } from "../db.ts";
7070
71const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const;71const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const;
72const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes";
73const relayCorsAllowedHeaders = [72const 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",
2224
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]);
3438
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]);
5454
packages/client-runtime/README.md created+31
...@@ -0,0 +1,31 @@
1# Client Runtime
2
3Shared client behavior for web and mobile. Public APIs are organized by package
4subpath. 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
23Platform applications provide `platform` services. `connection` composes those
24capabilities with `authorization`, `relay`, and `rpc` to supervise environment
25sessions. Independent `state` modules consume the connection registry and expose
26focused state or Atom constructors to application-owned runtimes.
27
28Applications 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
31files 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 @@
1import { describe, expect, it } from "vite-plus/test";
2import {
3 EnvironmentId,
4 ProjectId,
5 CommandId,
6 SourceControlDiscoveryResult,
7} from "@t3tools/contracts";
8import * as Option from "effect/Option";
9
10import {
11 buildAddProjectRemoteSourceReadiness,
12 buildProjectCreateCommand,
13 findExistingAddProject,
14 getAddProjectInitialQuery,
15 resolveAddProjectPath,
16 sortAddProjectProviderSources,
17} from "./addProject.ts";
18import type { EnvironmentScopedProjectShell } from "./shellTypes.ts";
19
20describe("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 @@
1import type {
2 CommandId,
3 EnvironmentId,
4 OrchestrationCommand,
5 ProjectId,
6 SourceControlDiscoveryResult,
7 SourceControlProviderKind,
8 SourceControlRepositoryInfo,
9} from "@t3tools/contracts";
10import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts";
11import * as Arr from "effect/Array";
12import * as Option from "effect/Option";
13import * as Order from "effect/Order";
14
15import {
16 ensureBrowseDirectoryPath,
17 findProjectByPath,
18 inferProjectTitleFromPath,
19 isExplicitRelativeProjectPath,
20 isUnsupportedWindowsProjectPath,
21 resolveProjectPathForDispatch,
22} from "./projectPaths.ts";
23import type { EnvironmentScopedProjectShell } from "./shellTypes.ts";
24
25export type AddProjectRemoteProviderKind = Extract<
26 SourceControlProviderKind,
27 "github" | "gitlab" | "bitbucket" | "azure-devops"
28>;
29export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
30
31export type AddProjectRemoteSourceReadiness = Record<
32 AddProjectRemoteSource,
33 { readonly ready: boolean; readonly hint: string | null }
34>;
35
36export 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
51export const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
52 "url",
53 "github",
54 "gitlab",
55 "bitbucket",
56 "azure-devops",
57];
58
59export const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [
60 "github",
61 "gitlab",
62 "bitbucket",
63 "azure-devops",
64];
65
66export 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
81export 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
96export function addProjectRemoteSourceProvider(
97 source: AddProjectRemoteSource,
98): AddProjectRemoteProviderKind | null {
99 return source === "url" ? null : source;
100}
101
102export 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
120export 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
168export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string {
169 const trimmed = baseDirectory?.trim() ?? "";
170 return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed);
171}
172
173export 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
192export 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
205export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 classifyHostedHttpsCompatibility,
5 createAdvertisedEndpoint,
6 deriveWsBaseUrl,
7 normalizeHttpBaseUrl,
8} from "./advertisedEndpoint.ts";
9
10const coreProvider = {
11 id: "desktop-core",
12 label: "Desktop",
13 kind: "core",
14 isAddon: false,
15} as const;
16
17describe("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 @@
1export * from "@t3tools/shared/advertisedEndpoint";
packages/client-runtime/src/archivedThreadsState.test.ts deleted-96
...@@ -1,96 +0,0 @@
1import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 type ArchivedThreadsClient,
7 createArchivedThreadsManager,
8 makeArchivedThreadsEnvironmentKey,
9 parseArchivedThreadsEnvironmentKey,
10 readArchivedThreadsSnapshotState,
11} from "./archivedThreadsState.ts";
12
13let registry = AtomRegistry.make();
14
15function resetAtomRegistry() {
16 registry.dispose();
17 registry = AtomRegistry.make();
18}
19
20function 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
30describe("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 @@
1import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
2import * as Arr from "effect/Array";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import { pipe } from "effect/Function";
6import * as Order from "effect/Order";
7import * as Option from "effect/Option";
8import * as Result from "effect/Result";
9import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity";
10
11export type ArchivedSnapshotEntry = {
12 readonly environmentId: EnvironmentId;
13 readonly snapshot: OrchestrationShellSnapshot;
14};
15
16export interface ArchivedThreadsClient {
17 readonly getArchivedShellSnapshot: () => Promise<OrchestrationShellSnapshot>;
18}
19
20export interface ArchivedThreadsSnapshotState {
21 readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>;
22 readonly error: string | null;
23 readonly isLoading: boolean;
24}
25
26const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f";
27const DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS = 5_000;
28const DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000;
29const environmentIdOrder = Order.String as Order.Order<EnvironmentId>;
30
31export 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
39export 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
49export 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
66export 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 @@
1export * from "./layer.ts";
2export * from "./remote.ts";
3export * from "./service.ts";
4export * from "./tokenStore.ts";
packages/client-runtime/src/authorization/layer.test.ts created+344
...@@ -0,0 +1,344 @@
1import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";
7
8import { ManagedRelayDpopSigner, ManagedRelayDpopSignerError } from "../relay/managedRelay.ts";
9import { remoteHttpClientLayer } from "../rpc/http.ts";
10import { ClientPresentation } from "../platform/capabilities.ts";
11import { RemoteEnvironmentAuthorization, type RelayEnvironmentAuthorization } from "./service.ts";
12import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts";
13import { remoteEnvironmentAuthorizationLayer } from "./layer.ts";
14
15const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
16const ENDPOINT = {
17 httpBaseUrl: "https://environment.example.test",
18 wsBaseUrl: "wss://environment.example.test",
19 providerKind: "cloudflare_tunnel" as const,
20};
21const 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};
33const BOOTSTRAP: RelayEnvironmentAuthorization = {
34 environmentId: ENVIRONMENT_ID,
35 endpoint: ENDPOINT,
36 credential: "relay-bootstrap",
37};
38
39function 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
52const websocketTicket = (ticket: string) =>
53 Response.json({
54 ticket,
55 expiresAt: "2026-06-06T01:00:00.000Z",
56 });
57
58const 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
67const 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
78const 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
159describe("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 @@
1import {
2 exchangeRemoteDpopAccessToken,
3 type RemoteEnvironmentAuthError,
4 resolveRemoteDpopWebSocketConnectionUrl,
5 resolveRemoteWebSocketConnectionUrl,
6} from "./remote.ts";
7import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts";
8import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts";
9import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
10import { environmentEndpointUrl } from "../environment/endpoint.ts";
11import { ClientPresentation } from "../platform/capabilities.ts";
12import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
13import { RemoteEnvironmentAuthorization } from "./service.ts";
14import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts";
15import * as Clock from "effect/Clock";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as Ref from "effect/Ref";
20import * as Result from "effect/Result";
21import { HttpClient } from "effect/unstable/http";
22
23const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000;
24const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2;
25
26function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {
27 return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"
28 ? error
29 : mapRemoteEnvironmentError(error);
30}
31
32const 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
40export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Schema from "effect/Schema";
6import * as TestClock from "effect/testing/TestClock";
7
8import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
9import {
10 bootstrapRemoteBearerSession,
11 exchangeRemoteDpopAccessToken,
12 fetchRemoteDpopSessionState,
13 fetchRemoteSessionState,
14 issueRemoteDpopWebSocketTicket,
15 issueRemoteWebSocketTicket,
16 RemoteEnvironmentAuthInvalidJsonError,
17 RemoteEnvironmentAuthTimeoutError,
18 resolveRemoteWebSocketConnectionUrl,
19} from "./remote.ts";
20import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
21import { remoteHttpClientLayer } from "../rpc/http.ts";
22
23const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
24
25type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit];
26
27const 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
42const 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
52const provideRemoteHttp = (fetchFn: typeof fetch) => Effect.provide(remoteHttpClientLayer(fetchFn));
53
54const 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
91describe("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 @@
1import {
2 AuthAccessTokenType,
3 type AuthClientPresentationMetadata,
4 AuthEnvironmentBootstrapTokenType,
5 AuthTokenExchangeGrantType,
6 type AuthEnvironmentScope,
7} from "@t3tools/contracts";
8import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
9import * as Effect from "effect/Effect";
10import { environmentEndpointUrl } from "../environment/endpoint.ts";
11import {
12 executeEnvironmentHttpRequest,
13 makeEnvironmentHttpApiClient,
14 type RemoteEnvironmentRequestError,
15} from "../rpc/http.ts";
16
17export {
18 RemoteEnvironmentAuthFetchError,
19 RemoteEnvironmentAuthInvalidJsonError,
20 RemoteEnvironmentAuthTimeoutError,
21 RemoteEnvironmentAuthUndeclaredStatusError,
22} from "../rpc/http.ts";
23export type RemoteEnvironmentAuthError = RemoteEnvironmentRequestError;
24
25const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000;
26
27const 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
35export 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
64export 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
91export 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
110export 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
131export 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
150export 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
171export 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
193export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";
3import * as Context from "effect/Context";
4import type * as Effect from "effect/Effect";
5
6import type { ConnectionAttemptError, PreparedHttpAuthorization } from "../connection/model.ts";
7
8export interface RelayEnvironmentAuthorization {
9 readonly environmentId: EnvironmentId;
10 readonly endpoint: RelayManagedEndpoint;
11 readonly credential: string;
12}
13
14export interface AuthorizedRemoteEnvironment {
15 readonly environmentId: EnvironmentId;
16 readonly label: string;
17 readonly httpBaseUrl: string;
18 readonly socketUrl: string;
19 readonly httpAuthorization: PreparedHttpAuthorization;
20}
21
22export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { RelayManagedEndpoint } from "@t3tools/contracts/relay";
3import * as Context from "effect/Context";
4import type * as Effect from "effect/Effect";
5import type * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7
8import type { ConnectionAttemptError } from "../connection/model.ts";
9
10export 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
21export 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 @@
1import { EnvironmentId, ThreadId, type OrchestrationGetTurnDiffResult } from "@t3tools/contracts";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 type CheckpointDiffClient,
7 createCheckpointDiffManager,
8 EMPTY_CHECKPOINT_DIFF_STATE,
9 getCheckpointDiffTargetKey,
10} from "./checkpointDiffState.ts";
11
12let registry = AtomRegistry.make();
13
14function resetAtomRegistry() {
15 registry.dispose();
16 registry = AtomRegistry.make();
17}
18
19const TARGET = {
20 environmentId: EnvironmentId.make("env-local"),
21 threadId: ThreadId.make("thread-1"),
22 fromTurnCount: 1,
23 toTurnCount: 2,
24 ignoreWhitespace: false,
25};
26
27const PATCH_RESULT: OrchestrationGetTurnDiffResult = {
28 threadId: TARGET.threadId,
29 diff: "patch",
30 fromTurnCount: 1,
31 toTurnCount: 2,
32};
33
34function createClient() {
35 return {
36 getTurnDiff: vi.fn(async () => PATCH_RESULT),
37 getFullThreadDiff: vi.fn(async () => PATCH_RESULT),
38 } satisfies CheckpointDiffClient;
39}
40
41describe("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 @@
1import {
2 type EnvironmentId,
3 OrchestrationGetFullThreadDiffInput,
4 type OrchestrationGetFullThreadDiffResult,
5 OrchestrationGetTurnDiffInput,
6 type OrchestrationGetTurnDiffResult,
7 type ThreadId,
8} from "@t3tools/contracts";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
14
15export type CheckpointDiffResult =
16 | OrchestrationGetTurnDiffResult
17 | OrchestrationGetFullThreadDiffResult;
18
19export interface CheckpointDiffState {
20 readonly data: CheckpointDiffResult | null;
21 readonly error: string | null;
22 readonly isPending: boolean;
23}
24
25export 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
34export interface CheckpointDiffClient {
35 readonly getTurnDiff: (
36 input: OrchestrationGetTurnDiffInput,
37 ) => Promise<OrchestrationGetTurnDiffResult>;
38 readonly getFullThreadDiff: (
39 input: OrchestrationGetFullThreadDiffInput,
40 ) => Promise<OrchestrationGetFullThreadDiffResult>;
41}
42
43export const EMPTY_CHECKPOINT_DIFF_STATE = Object.freeze<CheckpointDiffState>({
44 data: null,
45 error: null,
46 isPending: false,
47});
48
49const INITIAL_CHECKPOINT_DIFF_STATE = Object.freeze<CheckpointDiffState>({
50 data: null,
51 error: null,
52 isPending: true,
53});
54
55const knownCheckpointDiffKeys = new Set<string>();
56
57export 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
65export const EMPTY_CHECKPOINT_DIFF_ATOM = Atom.make(EMPTY_CHECKPOINT_DIFF_STATE).pipe(
66 Atom.keepAlive,
67 Atom.withLabel("checkpoint-diff:null"),
68);
69
70const decodeFullThreadDiffInput = Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput);
71const decodeTurnDiffInput = Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput);
72
73type CheckpointDiffRequest =
74 | {
75 readonly kind: "fullThreadDiff";
76 readonly input: OrchestrationGetFullThreadDiffInput;
77 }
78 | {
79 readonly kind: "turnDiff";
80 readonly input: OrchestrationGetTurnDiffInput;
81 };
82
83export 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
99function 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
116function 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
126export 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
153function 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
162function 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
169export 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 @@
1import { assert, beforeEach, it, vi } from "vite-plus/test";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { AtomRegistry } from "effect/unstable/reactivity";
4
5import {
6 type ComposerPathSearchClient,
7 createComposerPathSearchManager,
8 EMPTY_COMPOSER_PATH_SEARCH_STATE,
9 getComposerPathSearchTargetKey,
10} from "./composerPathSearchState.ts";
11
12let registry = AtomRegistry.make();
13
14const noop = () => undefined;
15
16beforeEach(() => {
17 registry.dispose();
18 registry = AtomRegistry.make();
19});
20
21function flushAsyncWork(): Promise<void> {
22 return Promise.resolve().then(() => undefined);
23}
24
25const TARGET = {
26 environmentId: "env-local" as EnvironmentId,
27 cwd: "/repo",
28 query: "src",
29};
30
31it("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
37it("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
65it("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
90it("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
139it("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
175it("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 @@
1import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts";
2import * as Clock from "effect/Clock";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
7
8export interface ComposerPathSearchEntry {
9 readonly path: string;
10 readonly kind: "file" | "directory";
11}
12
13export interface ComposerPathSearchState {
14 readonly entries: ReadonlyArray<ComposerPathSearchEntry>;
15 readonly isPending: boolean;
16 readonly error: string | null;
17}
18
19export interface ComposerPathSearchTarget {
20 readonly environmentId: EnvironmentId | null;
21 readonly cwd: string | null;
22 readonly query: string | null;
23}
24
25export interface ComposerPathSearchClient {
26 readonly searchEntries: (input: {
27 readonly cwd: string;
28 readonly query: string;
29 readonly limit: number;
30 }) => Promise<ProjectSearchEntriesResult>;
31}
32
33interface WatchedEntry {
34 refCount: number;
35 target: ComposerPathSearchTarget & {
36 readonly environmentId: EnvironmentId;
37 readonly cwd: string;
38 };
39 teardown: () => void;
40}
41
42export const EMPTY_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({
43 entries: [],
44 isPending: false,
45 error: null,
46});
47
48const PENDING_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({
49 entries: [],
50 isPending: true,
51 error: null,
52});
53
54const NOOP: () => void = () => undefined;
55const DEFAULT_DEBOUNCE_MS = 200;
56const DEFAULT_LIMIT = 20;
57
58export 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
65export 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
70export function normalizeComposerPathSearchQuery(query: string | null): string {
71 return query?.trim() ?? "";
72}
73
74export 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
83function toSearchEntries(
84 entries: ProjectSearchEntriesResult["entries"],
85): ReadonlyArray<ComposerPathSearchEntry> {
86 return entries;
87}
88
89export 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 @@
1import { DesktopSshEnvironmentTargetSchema, EnvironmentId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6
7import type { ConnectionAttemptError } from "./model.ts";
8import {
9 BearerConnectionTarget,
10 PrimaryConnectionTarget,
11 RelayConnectionTarget,
12 SshConnectionTarget,
13 type ConnectionTarget,
14} from "./model.ts";
15
16const ConnectionProfileBase = {
17 connectionId: Schema.String,
18 environmentId: EnvironmentId,
19 label: Schema.String,
20};
21
22export class BearerConnectionProfile extends Schema.TaggedClass<BearerConnectionProfile>()(
23 "BearerConnectionProfile",
24 {
25 ...ConnectionProfileBase,
26 httpBaseUrl: Schema.String,
27 wsBaseUrl: Schema.String,
28 },
29) {}
30
31export class SshConnectionProfile extends Schema.TaggedClass<SshConnectionProfile>()(
32 "SshConnectionProfile",
33 {
34 ...ConnectionProfileBase,
35 target: DesktopSshEnvironmentTargetSchema,
36 },
37) {}
38
39export const ConnectionProfile = Schema.Union([BearerConnectionProfile, SshConnectionProfile]);
40export type ConnectionProfile = typeof ConnectionProfile.Type;
41
42export interface ConnectionCatalogEntry {
43 readonly target: ConnectionTarget;
44 readonly profile: Option.Option<ConnectionProfile>;
45}
46
47export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()(
48 "BearerConnectionCredential",
49 {
50 token: Schema.String,
51 },
52) {}
53
54export const ConnectionCredential = Schema.Union([BearerConnectionCredential]);
55export type ConnectionCredential = typeof ConnectionCredential.Type;
56
57export class PrimaryConnectionRegistration extends Schema.TaggedClass<PrimaryConnectionRegistration>()(
58 "PrimaryConnectionRegistration",
59 {
60 target: PrimaryConnectionTarget,
61 },
62) {}
63
64export class RelayConnectionRegistration extends Schema.TaggedClass<RelayConnectionRegistration>()(
65 "RelayConnectionRegistration",
66 {
67 target: RelayConnectionTarget,
68 },
69) {}
70
71export class BearerConnectionRegistration extends Schema.TaggedClass<BearerConnectionRegistration>()(
72 "BearerConnectionRegistration",
73 {
74 target: BearerConnectionTarget,
75 profile: BearerConnectionProfile,
76 credential: BearerConnectionCredential,
77 },
78) {}
79
80export class SshConnectionRegistration extends Schema.TaggedClass<SshConnectionRegistration>()(
81 "SshConnectionRegistration",
82 {
83 target: SshConnectionTarget,
84 profile: SshConnectionProfile,
85 },
86) {}
87
88export const ConnectionRegistration = Schema.Union([
89 RelayConnectionRegistration,
90 BearerConnectionRegistration,
91 SshConnectionRegistration,
92]);
93export type ConnectionRegistration = typeof ConnectionRegistration.Type;
94
95export function connectionRegistrationTarget(
96 registration: ConnectionRegistration | PrimaryConnectionRegistration,
97): ConnectionTarget {
98 return registration.target;
99}
100
101export 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
120export 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
131export 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 @@
1import * as Context from "effect/Context";
2import type * as Effect from "effect/Effect";
3import type * as Stream from "effect/Stream";
4
5import type { NetworkStatus } from "./model.ts";
6
7export 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 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import type * as Scope from "effect/Scope";
5
6import type { ConnectionCatalogEntry } from "./catalog.ts";
7import type {
8 ConnectionAttemptError,
9 ConnectionAttemptStage,
10 PreparedConnection,
11} from "./model.ts";
12import { ConnectionResolver } from "./resolver.ts";
13import { RpcSessionFactory, type RpcSession } from "../rpc/session.ts";
14
15export type ConnectionDriverProgress =
16 | {
17 readonly stage: "preparing";
18 }
19 | {
20 readonly stage: Exclude<ConnectionAttemptStage, "preparing">;
21 readonly prepared: PreparedConnection;
22 };
23
24export interface EnvironmentConnectionLease {
25 readonly prepared: PreparedConnection;
26 readonly session: RpcSession;
27}
28
29export interface ConnectionDriverService {
30 readonly connect: (
31 entry: ConnectionCatalogEntry,
32 reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>,
33 ) => Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, Scope.Scope>;
34}
35
36export class ConnectionDriver extends Context.Service<ConnectionDriver, ConnectionDriverService>()(
37 "@t3tools/client-runtime/connection/driver/ConnectionDriver",
38) {}
39
40export 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 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import type { RelayProtectedError } from "@t3tools/contracts/relay";
3import type { ManagedRelayClientError } from "../relay/managedRelay.ts";
4import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";
5import {
6 ConnectionBlockedError,
7 type ConnectionAttemptError,
8 ConnectionTransientError,
9} from "./model.ts";
10
11export function profileMissingError(connectionId: string): ConnectionBlockedError {
12 return new ConnectionBlockedError({
13 reason: "configuration",
14 message: `Connection profile ${connectionId} is unavailable.`,
15 });
16}
17
18export function credentialMissingError(connectionId: string): ConnectionBlockedError {
19 return new ConnectionBlockedError({
20 reason: "authentication",
21 message: `Connection credential ${connectionId} is unavailable.`,
22 });
23}
24
25export 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
35function 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
76export 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
94export 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 @@
1export * from "./catalog.ts";
2export * from "./connectivity.ts";
3export * from "./driver.ts";
4export * from "./errors.ts";
5export * from "./layer.ts";
6export * from "./model.ts";
7export * from "./onboarding.ts";
8export * from "./presentation.ts";
9export * from "./registry.ts";
10export * from "./resolver.ts";
11export * from "./supervisor.ts";
12export * from "./wakeups.ts";
packages/client-runtime/src/connection/layer.ts created+46
...@@ -0,0 +1,46 @@
1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Stream from "effect/Stream";
4
5import { connectionResolverLayer } from "./resolver.ts";
6import { connectionDriverLayer } from "./driver.ts";
7import { environmentRegistryLayer, EnvironmentRegistry } from "./registry.ts";
8import { connectionOnboardingLayer } from "./onboarding.ts";
9import { PlatformConnectionSource } from "../platform/source.ts";
10import { relayEnvironmentDiscoveryLayer } from "../relay/discovery.ts";
11import { remoteEnvironmentAuthorizationLayer } from "../authorization/layer.ts";
12import { rpcSessionFactoryLayer } from "../rpc/session.ts";
13
14const resolverLayer = connectionResolverLayer.pipe(
15 Layer.provide(remoteEnvironmentAuthorizationLayer),
16);
17
18const driverLayer = connectionDriverLayer.pipe(
19 Layer.provide(Layer.mergeAll(resolverLayer, rpcSessionFactoryLayer)),
20);
21
22const registryLayer = environmentRegistryLayer.pipe(Layer.provide(driverLayer));
23
24const onboardingLayer = connectionOnboardingLayer.pipe(Layer.provide(registryLayer));
25
26const connectionServicesLayer = Layer.mergeAll(
27 registryLayer,
28 relayEnvironmentDiscoveryLayer,
29 onboardingLayer,
30);
31
32const 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
44export const connectionLayer = connectionStartupLayer.pipe(
45 Layer.provideMerge(connectionServicesLayer),
46);
packages/client-runtime/src/connection/model.ts created+168
...@@ -0,0 +1,168 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3
4const ConnectionTargetBase = {
5 environmentId: EnvironmentId,
6 label: Schema.String,
7};
8
9export class PrimaryConnectionTarget extends Schema.TaggedClass<PrimaryConnectionTarget>()(
10 "PrimaryConnectionTarget",
11 {
12 ...ConnectionTargetBase,
13 httpBaseUrl: Schema.String,
14 wsBaseUrl: Schema.String,
15 },
16) {}
17
18export class BearerConnectionTarget extends Schema.TaggedClass<BearerConnectionTarget>()(
19 "BearerConnectionTarget",
20 {
21 ...ConnectionTargetBase,
22 connectionId: Schema.String,
23 },
24) {}
25
26export class RelayConnectionTarget extends Schema.TaggedClass<RelayConnectionTarget>()(
27 "RelayConnectionTarget",
28 {
29 ...ConnectionTargetBase,
30 },
31) {}
32
33export class SshConnectionTarget extends Schema.TaggedClass<SshConnectionTarget>()(
34 "SshConnectionTarget",
35 {
36 ...ConnectionTargetBase,
37 connectionId: Schema.String,
38 },
39) {}
40
41export const ConnectionTarget = Schema.Union([
42 PrimaryConnectionTarget,
43 BearerConnectionTarget,
44 RelayConnectionTarget,
45 SshConnectionTarget,
46]);
47export type ConnectionTarget = typeof ConnectionTarget.Type;
48
49export const PersistedConnectionTarget = Schema.Union([
50 BearerConnectionTarget,
51 RelayConnectionTarget,
52 SshConnectionTarget,
53]);
54export type PersistedConnectionTarget = typeof PersistedConnectionTarget.Type;
55
56export type ConnectionTargetKind = ConnectionTarget["_tag"];
57
58export type NetworkStatus = "unknown" | "offline" | "online";
59
60export type ConnectionTransientReason =
61 | "network"
62 | "timeout"
63 | "transport"
64 | "endpoint-unavailable"
65 | "relay-unavailable"
66 | "remote-unavailable";
67
68export type ConnectionBlockedReason =
69 | "authentication"
70 | "configuration"
71 | "permission"
72 | "unsupported";
73
74export 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
90export 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
99export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError;
100
101export type PreparedHttpAuthorization =
102 | {
103 readonly _tag: "Bearer";
104 readonly token: string;
105 }
106 | {
107 readonly _tag: "Dpop";
108 readonly accessToken: string;
109 };
110
111export 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
120export type SupervisorConnectionPhase =
121 | "available"
122 | "offline"
123 | "connecting"
124 | "backoff"
125 | "connected"
126 | "blocked";
127
128export type ConnectionAttemptStage = "preparing" | "opening" | "synchronizing";
129
130export 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
141export type ConnectionProjectionPhase = "disconnected" | "synchronizing" | "ready";
142
143export 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
159export 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 @@
1import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import { remoteHttpClientLayer } from "../rpc/http.ts";
8import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts";
9import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts";
10import { BearerConnectionTarget } from "./model.ts";
11import {
12 prepareBearerConnectionUpdate,
13 preparePairingRegistration,
14 prepareSshRegistration,
15} from "./onboarding.ts";
16
17const 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
29function 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
77describe("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 @@
1import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts";
2import { resolveRemotePairingTarget } from "@t3tools/shared/remote";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import * as SubscriptionRef from "effect/SubscriptionRef";
9import { HttpClient } from "effect/unstable/http";
10
11import { bootstrapRemoteBearerSession } from "../authorization/remote.ts";
12import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts";
13import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
14import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts";
15import {
16 BearerConnectionCredential,
17 BearerConnectionProfile,
18 BearerConnectionRegistration,
19 type ConnectionCatalogEntry,
20 type ConnectionCredential,
21 ConnectionCredentialStore,
22 SshConnectionProfile,
23 SshConnectionRegistration,
24} from "./catalog.ts";
25import { mapRemoteEnvironmentError } from "./errors.ts";
26import {
27 BearerConnectionTarget,
28 ConnectionBlockedError,
29 SshConnectionTarget,
30 type ConnectionAttemptError,
31} from "./model.ts";
32import type { ConnectionPersistenceError } from "../platform/persistence.ts";
33import { EnvironmentRegistry } from "./registry.ts";
34
35export interface PairingConnectionInput {
36 readonly pairingUrl?: string;
37 readonly host?: string;
38 readonly pairingCode?: string;
39}
40
41export interface SshConnectionInput {
42 readonly target: DesktopSshEnvironmentTarget;
43 readonly label?: string;
44}
45
46export interface BearerConnectionUpdateInput {
47 readonly environmentId: EnvironmentId;
48 readonly label: string;
49 readonly httpBaseUrl: string;
50}
51
52export 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
67const 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
80export 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
115export 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
124const isBearerCredential = Schema.is(BearerConnectionCredential);
125const isBearerProfile = Schema.is(BearerConnectionProfile);
126
127export 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
145export 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
207export 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
230export 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
239export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Option from "effect/Option";
4
5import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts";
6import {
7 BearerConnectionTarget,
8 ConnectionTransientError,
9 type SupervisorConnectionState,
10} from "./model.ts";
11import {
12 connectionCatalogDisplayUrl,
13 connectionPhaseMessage,
14 connectionStatusText,
15 presentEnvironmentConnection,
16 presentConnectionState,
17} from "./presentation.ts";
18
19const TARGET = new BearerConnectionTarget({
20 environmentId: EnvironmentId.make("environment-1"),
21 label: "Remote environment",
22 connectionId: "connection-1",
23});
24
25const 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
38function 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
52describe("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 @@
1import type { ServerConfig } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3
4import type { ConnectionCatalogEntry } from "./catalog.ts";
5import type { NetworkStatus, SupervisorConnectionState } from "./model.ts";
6
7export type EnvironmentConnectionPhase =
8 | "available"
9 | "offline"
10 | "connecting"
11 | "reconnecting"
12 | "connected"
13 | "error";
14
15export interface EnvironmentConnectionPresentation {
16 readonly phase: EnvironmentConnectionPhase;
17 readonly error: string | null;
18 readonly traceId: string | null;
19}
20
21export interface EnvironmentPresentation {
22 readonly entry: ConnectionCatalogEntry;
23 readonly connection: EnvironmentConnectionPresentation;
24 readonly serverConfig: ServerConfig | null;
25}
26
27export 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
58export 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
79export function presentEnvironmentConnection(
80 state: SupervisorConnectionState,
81): EnvironmentConnectionPresentation {
82 return presentConnectionState(state);
83}
84
85export 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
102export 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 @@
1import {
2 type DesktopSshEnvironmentTarget,
3 EnvironmentId,
4 type OrchestrationShellSnapshot,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "@effect/vitest";
7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Ref from "effect/Ref";
13import * as Result from "effect/Result";
14import * as Stream from "effect/Stream";
15import * as SubscriptionRef from "effect/SubscriptionRef";
16
17import { SshEnvironmentGateway } from "../platform/capabilities.ts";
18import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "../authorization/tokenStore.ts";
19import {
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";
32import { Connectivity } from "./connectivity.ts";
33import { ConnectionDriver } from "./driver.ts";
34import {
35 ConnectionTransientError,
36 BearerConnectionTarget,
37 PrimaryConnectionTarget,
38 RelayConnectionTarget,
39 SshConnectionTarget,
40 type ConnectionTarget,
41 type PreparedConnection,
42 type SupervisorConnectionState,
43} from "./model.ts";
44import {
45 ConnectionPersistenceError,
46 ConnectionRegistrationStore,
47 ConnectionTargetStore,
48 EnvironmentCacheStore,
49 EnvironmentOwnedDataCleanup,
50} from "../platform/persistence.ts";
51import { EnvironmentRegistry, environmentRegistryLayer } from "./registry.ts";
52import type { RpcSession } from "../rpc/session.ts";
53import { EnvironmentSupervisor } from "./supervisor.ts";
54import { ConnectionWakeups } from "./wakeups.ts";
55
56const 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});
62const 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
69const 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
78const RELAY_TARGET = new RelayConnectionTarget({
79 environmentId: EnvironmentId.make("environment-relay"),
80 label: "Relay environment",
81});
82const SECOND_RELAY_TARGET = new RelayConnectionTarget({
83 environmentId: EnvironmentId.make("environment-relay-2"),
84 label: "Second relay environment",
85});
86
87const BEARER_TARGET = new BearerConnectionTarget({
88 environmentId: EnvironmentId.make("environment-bearer"),
89 label: "Bearer environment",
90 connectionId: "bearer-connection",
91});
92const 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});
99const BEARER_CREDENTIAL = new BearerConnectionCredential({
100 token: "bearer-token",
101});
102
103const SSH_TARGET: DesktopSshEnvironmentTarget = {
104 alias: "test",
105 hostname: "test.example.test",
106 username: "developer",
107 port: 22,
108};
109const SSH_CONNECTION = new SshConnectionTarget({
110 environmentId: EnvironmentId.make("environment-ssh"),
111 label: "SSH environment",
112 connectionId: "ssh-connection",
113});
114const 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
121const CACHED_SNAPSHOT: OrchestrationShellSnapshot = {
122 snapshotSequence: 1,
123 projects: [],
124 threads: [],
125 updatedAt: "2026-06-06T00:00:00.000Z",
126};
127
128interface SessionControl {
129 readonly closed: Deferred.Deferred<never, ConnectionTransientError>;
130}
131
132const 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
403function 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
419describe("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 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Equal from "effect/Equal";
5import * as Exit from "effect/Exit";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Ref from "effect/Ref";
9import * as Schema from "effect/Schema";
10import * as Scope from "effect/Scope";
11import * as Semaphore from "effect/Semaphore";
12import * as Stream from "effect/Stream";
13import * as SubscriptionRef from "effect/SubscriptionRef";
14
15import { SshEnvironmentGateway } from "../platform/capabilities.ts";
16import {
17 type ConnectionCatalogEntry,
18 type ConnectionRegistration,
19 ConnectionProfileStore,
20 type PrimaryConnectionRegistration,
21 SshConnectionProfile,
22 connectionRegistrationCatalogEntry,
23} from "./catalog.ts";
24import { Connectivity } from "./connectivity.ts";
25import type {
26 ConnectionAttemptError,
27 ConnectionTarget,
28 NetworkStatus,
29 SupervisorConnectionState,
30} from "./model.ts";
31import {
32 type ConnectionPersistenceError,
33 ConnectionRegistrationStore,
34 ConnectionTargetStore,
35 EnvironmentCacheStore,
36 EnvironmentOwnedDataCleanup,
37} from "../platform/persistence.ts";
38import {
39 EnvironmentSupervisor,
40 type EnvironmentSupervisorService,
41 makeEnvironmentSupervisor,
42} from "./supervisor.ts";
43import { ConnectionDriver } from "./driver.ts";
44import { ConnectionWakeups } from "./wakeups.ts";
45
46const isSshConnectionProfile = Schema.is(SshConnectionProfile);
47
48export class EnvironmentNotRegisteredError extends Schema.TaggedErrorClass<EnvironmentNotRegisteredError>()(
49 "EnvironmentNotRegisteredError",
50 {
51 environmentId: Schema.String,
52 message: Schema.String,
53 },
54) {}
55
56export class PlatformEnvironmentRemovalError extends Schema.TaggedErrorClass<PlatformEnvironmentRemovalError>()(
57 "PlatformEnvironmentRemovalError",
58 {
59 environmentId: Schema.String,
60 message: Schema.String,
61 },
62) {}
63
64export 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
108export class EnvironmentRegistry extends Context.Service<
109 EnvironmentRegistry,
110 EnvironmentRegistryService
111>()("@t3tools/client-runtime/connection/registry/EnvironmentRegistry") {}
112
113interface EnvironmentServiceScope {
114 readonly entry: ConnectionCatalogEntry;
115 readonly supervisor: EnvironmentSupervisorService;
116 readonly scope: Scope.Closeable;
117}
118
119const 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
573export const environmentRegistryLayer = Layer.effect(
574 EnvironmentRegistry,
575 makeEnvironmentRegistry(),
576);
packages/client-runtime/src/connection/resolver.test.ts created+423
...@@ -0,0 +1,423 @@
1import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts";
2import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
3import { RelayClientTracer } from "@t3tools/shared/relayTracing";
4import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Ref from "effect/Ref";
9import * as Tracer from "effect/Tracer";
10
11import {
12 ManagedRelayClient,
13 ManagedRelayClientError,
14 ManagedRelayRequestTimeoutError,
15} from "../relay/managedRelay.ts";
16import { ConnectionResolver } from "./resolver.ts";
17import { connectionResolverLayer } from "./resolver.ts";
18import {
19 CloudSession,
20 RelayDeviceIdentity,
21 SshEnvironmentGateway,
22} from "../platform/capabilities.ts";
23import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
24import {
25 BearerConnectionCredential,
26 BearerConnectionProfile,
27 type ConnectionCatalogEntry,
28 ConnectionCredentialStore,
29 ConnectionProfileStore,
30 SshConnectionProfile,
31 type ConnectionCredential,
32 type ConnectionProfile,
33} from "./catalog.ts";
34import {
35 BearerConnectionTarget,
36 ConnectionTransientError,
37 PrimaryConnectionTarget,
38 RelayConnectionTarget,
39 SshConnectionTarget,
40 type ConnectionTarget,
41} from "./model.ts";
42
43const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
44const ENDPOINT = {
45 httpBaseUrl: "https://environment.example.test",
46 wsBaseUrl: "wss://environment.example.test",
47 providerKind: "cloudflare_tunnel" as const,
48};
49const SSH_TARGET: DesktopSshEnvironmentTarget = {
50 alias: "development",
51 hostname: "development.example.test",
52 username: "developer",
53 port: 22,
54};
55
56function catalogEntry(
57 target: ConnectionTarget,
58 profile: Option.Option<ConnectionProfile> = Option.none(),
59): ConnectionCatalogEntry {
60 return { target, profile };
61}
62
63function unsupported<A>(name: string): Effect.Effect<A> {
64 return Effect.die(new Error(`Unexpected relay call: ${name}`));
65}
66
67function 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
81function 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
98const 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
197describe("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 @@
1import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
2import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8
9import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
10import { ManagedRelayClient } from "../relay/managedRelay.ts";
11import {
12 CloudSession,
13 RelayDeviceIdentity,
14 SshEnvironmentGateway,
15} from "../platform/capabilities.ts";
16import {
17 BearerConnectionCredential,
18 BearerConnectionProfile,
19 type ConnectionCatalogEntry,
20 ConnectionCredentialStore,
21 ConnectionProfileStore,
22 SshConnectionProfile,
23} from "./catalog.ts";
24import {
25 credentialMissingError,
26 environmentMismatchError,
27 mapManagedRelayError,
28 profileMissingError,
29} from "./errors.ts";
30import type {
31 BearerConnectionTarget,
32 ConnectionTarget,
33 PreparedConnection,
34 PrimaryConnectionTarget,
35 RelayConnectionTarget,
36 SshConnectionTarget,
37} from "./model.ts";
38import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts";
39
40export 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
49const isBearerProfile = Schema.is(BearerConnectionProfile);
50const isSshProfile = Schema.is(SshConnectionProfile);
51const isBearerCredential = Schema.is(BearerConnectionCredential);
52
53function 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
61const 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
73const 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
125const 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
173const 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
228export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { RelayClientTracer } from "@t3tools/shared/relayTracing";
3import { describe, expect, it } from "@effect/vitest";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Ref from "effect/Ref";
9import * as Stream from "effect/Stream";
10import * as SubscriptionRef from "effect/SubscriptionRef";
11import * as TestClock from "effect/testing/TestClock";
12import * as Tracer from "effect/Tracer";
13
14import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
15import type { ConnectionCatalogEntry } from "./catalog.ts";
16import { Connectivity } from "./connectivity.ts";
17import {
18 ConnectionDriver,
19 type ConnectionDriverProgress,
20 type EnvironmentConnectionLease,
21} from "./driver.ts";
22import {
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";
33import type { RpcSession } from "../rpc/session.ts";
34import { makeEnvironmentSupervisor } from "./supervisor.ts";
35import { ConnectionWakeups } from "./wakeups.ts";
36
37const 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
44const RELAY_TARGET = new RelayConnectionTarget({
45 environmentId: TARGET.environmentId,
46 label: TARGET.label,
47});
48
49const TARGET_ENTRY: ConnectionCatalogEntry = {
50 target: TARGET,
51 profile: Option.none(),
52};
53
54const RELAY_ENTRY: ConnectionCatalogEntry = {
55 target: RELAY_TARGET,
56 profile: Option.none(),
57};
58
59const 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
68const TEST_RPC_CLIENT = {} as WsRpcProtocolClient;
69
70function transient(message = "Connection failed.") {
71 return new ConnectionTransientError({
72 reason: "transport",
73 message,
74 });
75}
76
77function blocked(message = "Authentication required.") {
78 return new ConnectionBlockedError({
79 reason: "authentication",
80 message,
81 });
82}
83
84function 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
95const 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
114const 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
219describe("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 @@
1import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
2import * as Cause from "effect/Cause";
3import * as Clock from "effect/Clock";
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Queue from "effect/Queue";
11import * as Ref from "effect/Ref";
12import * as Scope from "effect/Scope";
13import * as Stream from "effect/Stream";
14import * as SubscriptionRef from "effect/SubscriptionRef";
15import * as Tracer from "effect/Tracer";
16
17import type { ConnectionCatalogEntry } from "./catalog.ts";
18import { Connectivity } from "./connectivity.ts";
19import {
20 ConnectionDriver,
21 type ConnectionDriverProgress,
22 type EnvironmentConnectionLease,
23} from "./driver.ts";
24import {
25 type ConnectionAttemptError,
26 type ConnectionTarget,
27 ConnectionTransientError,
28 type NetworkStatus,
29 type PreparedConnection,
30 type SupervisorConnectionState,
31} from "./model.ts";
32import type { RpcSession } from "../rpc/session.ts";
33import { type ConnectionWakeup, ConnectionWakeups } from "./wakeups.ts";
34
35const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const;
36const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds";
37const CONNECTION_PROBE_TIMEOUT = "15 seconds";
38const BACKOFF_RESET_AFTER_MS = 30_000;
39
40interface SupervisorIntent {
41 readonly desired: boolean;
42 readonly network: NetworkStatus;
43}
44
45type 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
52interface PendingRetryTrace {
53 readonly previousAttempt: Tracer.Span;
54 readonly failureCount: number;
55 readonly delayMs: number;
56 readonly reason: ConnectionAttemptError["reason"];
57}
58
59interface TracedAttemptFailure {
60 readonly error: ConnectionAttemptError;
61 readonly attemptSpan: Option.Option<Tracer.Span>;
62}
63
64type 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
77type 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
91function 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
101export interface EnvironmentSupervisorOptions {
102 readonly initiallyDesired?: boolean;
103}
104
105export 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
115function retryDelayMs(failureCount: number): number {
116 return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000;
117}
118
119function 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
127function 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
140function 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
158function 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
177function 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
209export 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
225export 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 @@
1import * as Context from "effect/Context";
2import type * as Stream from "effect/Stream";
3
4export type ConnectionWakeup = "application-active" | "credentials-changed";
5
6export 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 @@
1import * as Effect from "effect/Effect";
2
3import { environmentEndpointUrl } from "./endpoint.ts";
4import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
5
6const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000;
7
8export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 classifyHostedHttpsCompatibility,
5 createAdvertisedEndpoint,
6 deriveWsBaseUrl,
7 normalizeHttpBaseUrl,
8} from "./endpoint.ts";
9
10const coreProvider = {
11 id: "desktop-core",
12 label: "Desktop",
13 kind: "core",
14 isAddon: false,
15} as const;
16
17describe("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 @@
1export * from "@t3tools/shared/advertisedEndpoint";
2
3export 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 @@
1export * from "./descriptor.ts";
2export * from "./endpoint.ts";
3export * from "./knownEnvironment.ts";
4export * from "./scoped.ts";
packages/client-runtime/src/environment/knownEnvironment.test.ts created+66
...@@ -0,0 +1,66 @@
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { createKnownEnvironment } from "./knownEnvironment.ts";
5import {
6 parseScopedProjectKey,
7 parseScopedThreadKey,
8 scopedProjectKey,
9 scopedRefKey,
10 scopedThreadKey,
11 scopeProjectRef,
12 scopeThreadRef,
13} from "./scoped.ts";
14
15describe("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
37describe("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 @@
1import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2
3export interface KnownEnvironmentConnectionTarget {
4 readonly httpBaseUrl: string;
5 readonly wsBaseUrl: string;
6}
7
8export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin";
9
10export interface KnownEnvironment {
11 readonly id: string;
12 readonly label: string;
13 readonly source: KnownEnvironmentSource;
14 readonly environmentId?: EnvironmentId;
15 readonly target: KnownEnvironmentConnectionTarget;
16}
17
18export 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
32export 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 @@
1import {
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
11export function scopeProjectRef(
12 environmentId: EnvironmentIdType,
13 projectId: ProjectIdType,
14): ScopedProjectRef {
15 return { environmentId, projectId };
16}
17
18export function scopeThreadRef(
19 environmentId: EnvironmentIdType,
20 threadId: ThreadId,
21): ScopedThreadRef {
22 return { environmentId, threadId };
23}
24
25export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string {
26 const localId = "projectId" in ref ? ref.projectId : ref.threadId;
27 return `${ref.environmentId}:${localId}`;
28}
29
30export function scopedProjectKey(ref: ScopedProjectRef): string {
31 return scopedRefKey(ref);
32}
33
34export function scopedThreadKey(ref: ScopedThreadRef): string {
35 return scopedRefKey(ref);
36}
37
38function 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
49export 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
60export 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 @@
1import type {
2 EnvironmentId,
3 OrchestrationShellSnapshot,
4 OrchestrationShellStreamEvent,
5 ServerConfig,
6 ServerLifecycleWelcomePayload,
7 TerminalEvent,
8} from "@t3tools/contracts";
9
10import type { KnownEnvironment } from "./knownEnvironment.ts";
11import type { WsRpcClient } from "./wsRpcClient.ts";
12
13export 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
23interface 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
35export 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
45export interface EnvironmentConnectionAttempt {
46 readonly environmentId: EnvironmentId;
47 readonly isCurrent: () => boolean;
48}
49
50export class EnvironmentConnectionAttemptCancelledError extends Error {
51 constructor(environmentId: EnvironmentId) {
52 super(`Environment connection attempt ${environmentId} was cancelled.`);
53 this.name = "EnvironmentConnectionAttemptCancelledError";
54 }
55}
56
57export 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
78export 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
85function 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
116export 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 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2import { EnvironmentId } from "@t3tools/contracts";
3import { afterEach, describe, expect, it } from "vite-plus/test";
4
5import { createEnvironmentRuntimeManager } from "./environmentRuntimeState.ts";
6
7let atomRegistry = AtomRegistry.make();
8
9function resetAtomRegistry() {
10 atomRegistry.dispose();
11 atomRegistry = AtomRegistry.make();
12}
13
14const TARGET = { environmentId: EnvironmentId.make("env-local") } as const;
15
16describe("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 @@
1import type { EnvironmentId, ServerConfig as T3ServerConfig } from "@t3tools/contracts";
2import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
3
4export type EnvironmentConnectionState =
5 | "idle"
6 | "connecting"
7 | "ready"
8 | "reconnecting"
9 | "disconnected";
10
11export interface EnvironmentRuntimeState {
12 readonly connectionState: EnvironmentConnectionState;
13 readonly connectionError: string | null;
14 readonly serverConfig: T3ServerConfig | null;
15}
16
17export interface EnvironmentRuntimeTarget {
18 readonly environmentId: EnvironmentId | null;
19}
20
21export const EMPTY_ENVIRONMENT_RUNTIME_STATE = Object.freeze<EnvironmentRuntimeState>({
22 connectionState: "idle",
23 connectionError: null,
24 serverConfig: null,
25});
26
27const knownEnvironmentRuntimeKeys = new Set<string>();
28
29export 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
37export const EMPTY_ENVIRONMENT_RUNTIME_ATOM = Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe(
38 Atom.keepAlive,
39 Atom.withLabel("environment-runtime:null"),
40);
41
42export function getEnvironmentRuntimeTargetKey(target: EnvironmentRuntimeTarget): string | null {
43 return target.environmentId;
44}
45
46export interface EnvironmentRuntimeManagerConfig {
47 readonly getRegistry: () => AtomRegistry.AtomRegistry;
48}
49
50export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { findErrorTraceId } from "./errorTrace.ts";
4
5describe("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 @@
1export 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 @@
1export * from "./errorTrace.ts";
2export * from "./transport.ts";
packages/client-runtime/src/errors/transport.test.ts created+72
...@@ -0,0 +1,72 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transport.ts";
4
5describe("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
56describe("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 @@
1const 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 */
19export 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 */
37export 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 @@
1import { assert, beforeEach, it } from "vite-plus/test";
2import type { FilesystemBrowseResult } from "@t3tools/contracts";
3import { AtomRegistry } from "effect/unstable/reactivity";
4
5import {
6 EMPTY_FILESYSTEM_BROWSE_STATE,
7 createFilesystemBrowseManager,
8} from "./filesystemBrowseState.ts";
9
10const ROOT_RESULT: FilesystemBrowseResult = {
11 parentPath: "/Users/julius",
12 entries: [
13 {
14 name: "code",
15 fullPath: "/Users/julius/code",
16 },
17 ],
18};
19
20let registry = AtomRegistry.make();
21
22beforeEach(() => {
23 registry.dispose();
24 registry = AtomRegistry.make();
25});
26
27function unresolvedBrowse() {
28 throw new Error("Browse resolver was not initialized.");
29}
30
31function flushAsyncWork(): Promise<void> {
32 return Promise.resolve().then(() => undefined);
33}
34
35it("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
59it("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
96it("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 @@
1import type { FilesystemBrowseInput, FilesystemBrowseResult } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
4
5export interface FilesystemBrowseState {
6 readonly data: FilesystemBrowseResult | null;
7 readonly error: string | null;
8 readonly isPending: boolean;
9}
10
11export interface FilesystemBrowseTarget<TKey extends string = string> {
12 readonly key: TKey | null;
13 readonly input: FilesystemBrowseInput | null;
14}
15
16export interface FilesystemBrowseClient {
17 readonly browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>;
18}
19
20interface WatchedEntry {
21 refCount: number;
22 teardown: () => void;
23}
24
25export const EMPTY_FILESYSTEM_BROWSE_STATE = Object.freeze<FilesystemBrowseState>({
26 data: null,
27 error: null,
28 isPending: false,
29});
30
31const INITIAL_FILESYSTEM_BROWSE_STATE = Object.freeze<FilesystemBrowseState>({
32 data: null,
33 error: null,
34 isPending: true,
35});
36
37const knownFilesystemBrowseKeys = new Set<string>();
38
39export 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
47export const EMPTY_FILESYSTEM_BROWSE_ATOM = Atom.make(EMPTY_FILESYSTEM_BROWSE_STATE).pipe(
48 Atom.keepAlive,
49 Atom.withLabel("filesystem-browse:null"),
50);
51
52const NOOP: () => void = () => undefined;
53const DEFAULT_STALE_TIME_MS = 30_000;
54const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
55
56export 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
68export 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
76export 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 @@
1import type { VcsStatusResult } from "@t3tools/contracts";
2import { assert, describe, it } from "vite-plus/test";
3
4import { resolveLiveThreadBranchUpdate } from "./gitActions.js";
5
6function 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
25describe("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 @@
1import type {
2 GitRunStackedActionInput,
3 GitRunStackedActionResult,
4 GitStackedAction,
5 VcsStatusResult,
6} from "@t3tools/contracts";
7import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
8
9export type GitActionIconName = "commit" | "push" | "pr";
10
11export type GitDialogAction = "commit" | "push" | "create_pr";
12
13export 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
22export 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
30export interface DefaultBranchActionDialogCopy {
31 title: string;
32 description: string;
33 continueLabel: string;
34}
35
36export type DefaultBranchConfirmableAction =
37 | "push"
38 | "create_pr"
39 | "commit_push"
40 | "commit_push_pr";
41
42export type GitActionRequestInput = Pick<
43 GitRunStackedActionInput,
44 "action" | "commitMessage" | "featureBranch" | "filePaths"
45>;
46
47export 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
85export 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
151export 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
286export 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
350export 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
363export 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
400export 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
412export 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 @@
1export * from "./advertisedEndpoint.ts";
2export * from "./knownEnvironment.ts";
3export * from "./reconnectBackoff.ts";
4export * from "./scoped.ts";
5export * from "./projectPaths.ts";
6export * from "./addProject.ts";
7export * from "./filesystemBrowseState.ts";
8export * from "./sourceControlDiscoveryState.ts";
9export * from "./environmentRuntimeState.ts";
10export * from "./shellTypes.ts";
11export * from "./shellSnapshotReducer.ts";
12export * from "./shellSnapshotState.ts";
13export * from "./threadDetailReducer.ts";
14export * from "./threadDetailState.ts";
15export * from "./gitActions.ts";
16export * from "./vcsActionState.ts";
17export * from "./vcsRefState.ts";
18export * from "./vcsStatusState.ts";
19export * from "./terminalSessionState.ts";
20export * from "./transportError.ts";
21export * from "./wsRpcProtocol.ts";
22export * from "./wsTransport.ts";
23export * from "./wsRpcClient.ts";
24export * from "./environmentConnection.ts";
25export * from "./composerPathSearchState.ts";
26export * from "./archivedThreadsState.ts";
27export * from "./checkpointDiffState.ts";
28export * from "./remote.ts";
29export * from "./managedRelay.ts";
30export * from "./managedRelayState.ts";
packages/client-runtime/src/knownEnvironment.test.ts deleted-92
...@@ -1,92 +0,0 @@
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment.ts";
5import {
6 parseScopedProjectKey,
7 parseScopedThreadKey,
8 scopedProjectKey,
9 scopedRefKey,
10 scopedThreadKey,
11 scopeProjectRef,
12 scopeThreadRef,
13} from "./scoped.ts";
14
15describe("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
63describe("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 @@
1import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2
3export interface KnownEnvironmentConnectionTarget {
4 readonly httpBaseUrl: string;
5 readonly wsBaseUrl: string;
6}
7
8export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin";
9
10export interface KnownEnvironment {
11 readonly id: string;
12 readonly label: string;
13 readonly source: KnownEnvironmentSource;
14 readonly environmentId?: EnvironmentId;
15 readonly target: KnownEnvironmentConnectionTarget;
16}
17
18export 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
32export function getKnownEnvironmentWsBaseUrl(
33 environment: KnownEnvironment | null | undefined,
34): string | null {
35 return environment?.target.wsBaseUrl ?? null;
36}
37
38export function getKnownEnvironmentHttpBaseUrl(
39 environment: KnownEnvironment | null | undefined,
40): string | null {
41 return environment?.target.httpBaseUrl ?? null;
42}
43
44export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay";
3import { describe, expect, it } from "@effect/vitest";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as TestClock from "effect/testing/TestClock";
9
10import {
11 MANAGED_RELAY_REQUEST_TIMEOUT_MS,
12 ManagedRelayClient,
13 ManagedRelayDpopSigner,
14 managedRelayClientLayer,
15 type ManagedRelayDpopProofInput,
16} from "./managedRelay.ts";
17import { remoteHttpClientLayer } from "./remote.ts";
18
19function 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
37describe("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 @@
1import {
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";
28import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";
29import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
30import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
31import * as Clock from "effect/Clock";
32import * as Context from "effect/Context";
33import * as Data from "effect/Data";
34import * as Duration from "effect/Duration";
35import * as Effect from "effect/Effect";
36import * as Layer from "effect/Layer";
37import * as Option from "effect/Option";
38import * as SynchronizedRef from "effect/SynchronizedRef";
39import type { HttpMethod } from "effect/unstable/http/HttpMethod";
40import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
41
42export interface ManagedRelayDpopProofInput {
43 readonly method: HttpMethod;
44 readonly url: string;
45 readonly accessToken?: string;
46}
47
48export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{
49 readonly cause: unknown;
50}> {}
51
52export interface ManagedRelayDpopSignerShape {
53 readonly thumbprint: Effect.Effect<string, ManagedRelayDpopSignerError>;
54 readonly createProof: (
55 input: ManagedRelayDpopProofInput,
56 ) => Effect.Effect<string, ManagedRelayDpopSignerError>;
57}
58
59export class ManagedRelayDpopSigner extends Context.Service<
60 ManagedRelayDpopSigner,
61 ManagedRelayDpopSignerShape
62>()("@t3tools/client-runtime/managedRelay/ManagedRelayDpopSigner") {}
63
64export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{
65 readonly message: string;
66 readonly cause?: unknown;
67}> {}
68
69export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000;
70
71interface CachedRelayAccessToken {
72 readonly clerkToken: string;
73 readonly thumbprint: string;
74 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
75 readonly accessToken: string;
76 readonly expiresAtMillis: number;
77}
78
79export interface ManagedRelayAuthorization {
80 readonly accessToken: string;
81 readonly proof: string;
82 readonly thumbprint: string;
83}
84
85export interface ManagedRelayClientLayerOptions {
86 readonly relayUrl: string;
87 readonly clientId: RelayPublicClientId;
88}
89
90export 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
136export class ManagedRelayClient extends Context.Service<
137 ManagedRelayClient,
138 ManagedRelayClientShape
139>()("@t3tools/client-runtime/managedRelay/ManagedRelayClient") {}
140
141function relayClientError(message: string, cause?: unknown): ManagedRelayClientError {
142 return new ManagedRelayClientError({ message, ...(cause === undefined ? {} : { cause }) });
143}
144
145function 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
160function 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
177function bearerHeaders(clerkToken: string) {
178 return { authorization: `Bearer ${clerkToken}` };
179}
180
181function dpopHeaders(authorization: ManagedRelayAuthorization) {
182 return {
183 authorization: `DPoP ${authorization.accessToken}`,
184 dpop: authorization.proof,
185 };
186}
187
188function 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
207export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import type {
3 RelayClientDeviceRecord,
4 RelayClientEnvironmentRecord,
5 RelayEnvironmentStatusResponse,
6} from "@t3tools/contracts/relay";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import { Atom, AtomRegistry } from "effect/unstable/reactivity";
10import { afterEach, describe, expect, it, vi } from "vite-plus/test";
11
12import { ManagedRelayClient, type ManagedRelayClientShape } from "./managedRelay.ts";
13import {
14 createManagedRelayQueryManager,
15 createManagedRelaySession,
16 managedRelaySessionAtom,
17 readManagedRelaySnapshotState,
18 setManagedRelaySession,
19 waitForManagedRelayClerkToken,
20} from "./managedRelayState.ts";
21
22let registry = AtomRegistry.make();
23
24const 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
35const 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
54function resetRegistry() {
55 registry.dispose();
56 registry = AtomRegistry.make();
57}
58
59function 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
85function setSession() {
86 setManagedRelaySession(
87 registry,
88 createManagedRelaySession({
89 accountId: "account-1",
90 readClerkToken: () => Promise.resolve("clerk-token"),
91 }),
92 );
93}
94
95describe("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 @@
1import type {
2 RelayClientEnvironmentRecord,
3 RelayEnvironmentStatusResponse,
4} from "@t3tools/contracts/relay";
5import {
6 RelayEnvironmentConnectScope,
7 RelayEnvironmentStatusScope,
8} from "@t3tools/contracts/relay";
9import * as Cause from "effect/Cause";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Option from "effect/Option";
13import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity";
14
15import { ManagedRelayClient } from "./managedRelay.ts";
16
17const DEFAULT_STALE_TIME_MS = 15_000;
18const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
19
20export interface ManagedRelaySession {
21 readonly accountId: string;
22 readonly readClerkToken: () => Effect.Effect<string | null, ManagedRelaySessionError>;
23}
24
25export interface ManagedRelaySnapshotState<A> {
26 readonly data: A | null;
27 readonly error: string | null;
28 readonly isPending: boolean;
29}
30
31export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{
32 readonly message: string;
33 readonly cause?: unknown;
34}> {}
35
36export class ManagedRelaySnapshotError extends Data.TaggedError("ManagedRelaySnapshotError")<{
37 readonly message: string;
38}> {}
39
40export const managedRelaySessionAtom = Atom.make<ManagedRelaySession | null>(null).pipe(
41 Atom.keepAlive,
42 Atom.withLabel("managed-relay:session"),
43);
44
45export 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
63export function setManagedRelaySession(
64 registry: AtomRegistry.AtomRegistry,
65 session: ManagedRelaySession | null,
66): void {
67 registry.set(managedRelaySessionAtom, session);
68}
69
70function 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
86export 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
116function 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
131function statusKey(input: {
132 readonly accountId: string;
133 readonly environment: RelayClientEnvironmentRecord;
134}): string {
135 return JSON.stringify(input);
136}
137
138function 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
148function 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
159function 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
187export 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
202export 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 @@
1import {
2 CommandId,
3 EnvironmentId,
4 ORCHESTRATION_WS_METHODS,
5 ProjectId,
6 ThreadId,
7 type ClientOrchestrationCommand,
8} from "@t3tools/contracts";
9import { describe, expect, it } from "@effect/vitest";
10import * as Crypto from "effect/Crypto";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as SubscriptionRef from "effect/SubscriptionRef";
15
16import {
17 AVAILABLE_CONNECTION_STATE,
18 PrimaryConnectionTarget,
19 type PreparedConnection,
20} from "../connection/model.ts";
21import {
22 EnvironmentSupervisor,
23 type EnvironmentSupervisorService,
24} from "../connection/supervisor.ts";
25import type { RpcSession } from "../rpc/session.ts";
26import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
27import { archiveThread, createProject, stopThreadSession } from "./commands.ts";
28
29const 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
37const 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
44const 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
72describe("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 @@
1import {
2 CommandId,
3 ORCHESTRATION_WS_METHODS,
4 type ClientOrchestrationCommand,
5} from "@t3tools/contracts";
6import * as Crypto from "effect/Crypto";
7import * as DateTime from "effect/DateTime";
8import * as Effect from "effect/Effect";
9
10import type { EnvironmentSupervisor } from "../connection/supervisor.ts";
11import {
12 type EnvironmentRpcFailure,
13 type EnvironmentRpcSuccess,
14 type EnvironmentRpcUnavailableError,
15 request,
16} from "../rpc/client.ts";
17
18type CommandType = ClientOrchestrationCommand["type"];
19type CommandOf<T extends CommandType> = Extract<ClientOrchestrationCommand, { readonly type: T }>;
20type 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
31export type CreateProjectInput = CommandInput<"project.create">;
32export type UpdateProjectInput = CommandInput<"project.meta.update">;
33export type DeleteProjectInput = CommandInput<"project.delete">;
34export type CreateThreadInput = CommandInput<"thread.create">;
35export type DeleteThreadInput = CommandInput<"thread.delete">;
36export type ArchiveThreadInput = CommandInput<"thread.archive">;
37export type UnarchiveThreadInput = CommandInput<"thread.unarchive">;
38export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
39export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
40export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
41export type StartThreadTurnInput = CommandInput<"thread.turn.start">;
42export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">;
43export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">;
44export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">;
45export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">;
46export type StopThreadSessionInput = CommandInput<"thread.session.stop">;
47
48type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand;
49type CommandEffect = Effect.Effect<
50 EnvironmentRpcSuccess<DispatchTag>,
51 EnvironmentRpcFailure<DispatchTag> | EnvironmentRpcUnavailableError,
52 Crypto.Crypto | EnvironmentSupervisor
53>;
54
55function 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
65function 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
78function dispatch(command: ClientOrchestrationCommand) {
79 return request(ORCHESTRATION_WS_METHODS.dispatchCommand, command);
80}
81
82export 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
94export 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
104export 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
114export 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
126export 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
136export 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
146export 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
156export 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
166export 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
178export 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
189export 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
201export 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
213export 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
224export 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
235export 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
246export 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 @@
1export * from "./commands.ts";
2export * from "./projects.ts";
packages/client-runtime/src/operations/projects.test.ts created+146
...@@ -0,0 +1,146 @@
1import { describe, expect, it } from "vite-plus/test";
2import {
3 EnvironmentId,
4 ProjectId,
5 CommandId,
6 SourceControlDiscoveryResult,
7} from "@t3tools/contracts";
8import * as Option from "effect/Option";
9
10import {
11 buildAddProjectRemoteSourceReadiness,
12 buildProjectCreateCommand,
13 findExistingAddProject,
14 getAddProjectInitialQuery,
15 resolveAddProjectPath,
16 sortAddProjectProviderSources,
17} from "./projects.ts";
18import type { EnvironmentProject } from "../state/models.ts";
19
20describe("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 @@
1import type {
2 CommandId,
3 EnvironmentId,
4 OrchestrationCommand,
5 ProjectId,
6 SourceControlDiscoveryResult,
7 SourceControlProviderKind,
8 SourceControlRepositoryInfo,
9} from "@t3tools/contracts";
10import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts";
11import * as Arr from "effect/Array";
12import * as Option from "effect/Option";
13import * as Order from "effect/Order";
14
15import {
16 ensureBrowseDirectoryPath,
17 findProjectByPath,
18 inferProjectTitleFromPath,
19 isExplicitRelativeProjectPath,
20 isUnsupportedWindowsProjectPath,
21 resolveProjectPathForDispatch,
22} from "../state/projects.ts";
23import type { EnvironmentProject } from "../state/models.ts";
24
25export type AddProjectRemoteProviderKind = Extract<
26 SourceControlProviderKind,
27 "github" | "gitlab" | "bitbucket" | "azure-devops"
28>;
29export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
30
31export type AddProjectRemoteSourceReadiness = Record<
32 AddProjectRemoteSource,
33 { readonly ready: boolean; readonly hint: string | null }
34>;
35
36export 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
51const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
52 "url",
53 "github",
54 "gitlab",
55 "bitbucket",
56 "azure-devops",
57];
58
59const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [
60 "github",
61 "gitlab",
62 "bitbucket",
63 "azure-devops",
64];
65
66export 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
81export 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
96export function addProjectRemoteSourceProvider(
97 source: AddProjectRemoteSource,
98): AddProjectRemoteProviderKind | null {
99 return source === "url" ? null : source;
100}
101
102export 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
120export 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
168export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string {
169 const trimmed = baseDirectory?.trim() ?? "";
170 return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed);
171}
172
173export 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
192export 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
205export 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 @@
1import {
2 type AuthClientPresentationMetadata,
3 type AuthEnvironmentScope,
4 type DesktopSshEnvironmentBootstrap,
5 type DesktopSshEnvironmentTarget,
6 EnvironmentId,
7} from "@t3tools/contracts";
8import * as Context from "effect/Context";
9import type * as Effect from "effect/Effect";
10import type * as Option from "effect/Option";
11
12import type { ConnectionAttemptError } from "../connection/model.ts";
13
14export interface PreparedSshEnvironment {
15 readonly bootstrap: DesktopSshEnvironmentBootstrap;
16 readonly bearerToken: string;
17}
18
19export interface ProvisionedSshEnvironment extends PreparedSshEnvironment {
20 readonly environmentId: EnvironmentId;
21 readonly label: string;
22}
23
24export class CloudSession extends Context.Service<
25 CloudSession,
26 {
27 readonly clerkToken: Effect.Effect<string, ConnectionAttemptError>;
28 }
29>()("@t3tools/client-runtime/platform/capabilities/CloudSession") {}
30
31export 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
38export 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
46export 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 @@
1export * from "./capabilities.ts";
2export * from "./persistence.ts";
3export * from "./source.ts";
4export * from "./storageDocument.ts";
packages/client-runtime/src/platform/persistence.ts created+84
...@@ -0,0 +1,84 @@
1import {
2 type EnvironmentId,
3 type OrchestrationThread,
4 type OrchestrationShellSnapshot,
5 type ThreadId,
6} from "@t3tools/contracts";
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11
12import type { ConnectionRegistration } from "../connection/catalog.ts";
13import type { ConnectionTarget } from "../connection/model.ts";
14
15export 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
33export 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
40export 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
50export 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
78export 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 @@
1import * as Context from "effect/Context";
2import type * as Stream from "effect/Stream";
3
4import type { PrimaryConnectionRegistration } from "../connection/catalog.ts";
5
6export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3
4import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts";
5import {
6 BearerConnectionCredential,
7 BearerConnectionProfile,
8 BearerConnectionRegistration,
9 RelayConnectionRegistration,
10 SshConnectionProfile,
11 SshConnectionRegistration,
12} from "../connection/catalog.ts";
13import {
14 BearerConnectionTarget,
15 RelayConnectionTarget,
16 SshConnectionTarget,
17} from "../connection/model.ts";
18import {
19 EMPTY_CONNECTION_CATALOG_DOCUMENT,
20 registerConnectionInCatalog,
21 removeConnectionFromCatalog,
22} from "./storageDocument.ts";
23
24const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
25
26const BEARER_TARGET = new BearerConnectionTarget({
27 environmentId: ENVIRONMENT_ID,
28 label: "Remote",
29 connectionId: "bearer-1",
30});
31const 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});
38const BEARER_CREDENTIAL = new BearerConnectionCredential({
39 token: "bearer-token",
40});
41const 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
54describe("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 @@
1import * as Schema from "effect/Schema";
2
3import {
4 type ConnectionRegistration,
5 ConnectionCredential,
6 ConnectionProfile,
7} from "../connection/catalog.ts";
8import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts";
9import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts";
10
11export const StoredConnectionCredential = Schema.Struct({
12 connectionId: Schema.String,
13 credential: ConnectionCredential,
14});
15export type StoredConnectionCredential = typeof StoredConnectionCredential.Type;
16
17export 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});
24export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type;
25
26export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Object.freeze({
27 schemaVersion: 1,
28 targets: [],
29 profiles: [],
30 credentials: [],
31 remoteDpopTokens: [],
32});
33
34export 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
43export 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
51function 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
62function 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
93export 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
136export 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 @@
1import {
2 isExplicitRelativePath,
3 isUncPath,
4 isWindowsAbsolutePath,
5 isWindowsDrivePath,
6} from "@t3tools/shared/path";
7
8function isWindowsPlatform(platform: string): boolean {
9 return /^win(dows)?/i.test(platform);
10}
11
12function isRootPath(value: string): boolean {
13 return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
14}
15
16function 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
26function 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
40function preferredPathSeparator(value: string): "/" | "\\" {
41 const absolutePathKind = getAbsolutePathKind(value);
42 if (absolutePathKind === "windows") return "\\";
43 if (absolutePathKind === "unix") return "/";
44 return value.includes("\\") ? "\\" : "/";
45}
46
47export function hasTrailingPathSeparator(value: string): boolean {
48 return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value);
49}
50
51export { isExplicitRelativePath as isExplicitRelativeProjectPath };
52
53function splitPathSegments(value: string, separator: "/" | "\\"): string[] {
54 return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean);
55}
56
57function getLastPathSeparatorIndex(value: string): number {
58 if (getAbsolutePathKind(value) === "unix") {
59 return value.lastIndexOf("/");
60 }
61 return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
62}
63
64function 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
94export 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
107export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean {
108 return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform);
109}
110
111export function normalizeProjectPathForDispatch(value: string): string {
112 return trimTrailingPathSeparators(value.trim());
113}
114
115export 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
142export 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
150export 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
164export 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
174export function appendBrowsePathSegment(currentPath: string, segment: string): string {
175 const separator = preferredPathSeparator(currentPath);
176 return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`;
177}
178
179export function getBrowseLeafPathSegment(currentPath: string): string {
180 const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
181 return currentPath.slice(lastSeparatorIndex + 1);
182}
183
184export 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
192export 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
200export 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
219export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 DEFAULT_RECONNECT_BACKOFF,
5 getReconnectDelayMs,
6 type ReconnectBackoffConfig,
7} from "./reconnectBackoff.ts";
8
9describe("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
58describe("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 */
4export 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 */
20export 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 */
31export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import type {
3 RelayClientEnvironmentRecord,
4 RelayEnvironmentStatusResponse,
5} from "@t3tools/contracts/relay";
6import { describe, expect, it } from "@effect/vitest";
7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Ref from "effect/Ref";
13import * as Stream from "effect/Stream";
14import * as SubscriptionRef from "effect/SubscriptionRef";
15
16import {
17 ManagedRelayClient,
18 ManagedRelayClientError,
19 ManagedRelayRequestTimeoutError,
20 type ManagedRelayClientShape,
21} from "./managedRelay.ts";
22import { CloudSession } from "../platform/capabilities.ts";
23import { Connectivity } from "../connection/connectivity.ts";
24import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts";
25import { ConnectionWakeups } from "../connection/wakeups.ts";
26import { RelayEnvironmentDiscovery, relayEnvironmentDiscoveryLayer } from "./discovery.ts";
27
28const 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
51function 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
63const 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
171describe("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 @@
1import type {
2 RelayClientEnvironmentRecord,
3 RelayEnvironmentStatusResponse,
4} from "@t3tools/contracts/relay";
5import { decodeRelayJwt } from "@t3tools/shared/relayJwt";
6import {
7 RelayEnvironmentConnectScope,
8 RelayEnvironmentStatusScope,
9} from "@t3tools/contracts/relay";
10import * as Context from "effect/Context";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as Ref from "effect/Ref";
15import * as Semaphore from "effect/Semaphore";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18
19import { ManagedRelayClient } from "./managedRelay.ts";
20import { CloudSession } from "../platform/capabilities.ts";
21import { Connectivity } from "../connection/connectivity.ts";
22import { mapManagedRelayError } from "../connection/errors.ts";
23import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts";
24import { ConnectionWakeups } from "../connection/wakeups.ts";
25
26export type RelayEnvironmentAvailability = "checking" | "online" | "offline" | "error";
27
28export interface RelayDiscoveredEnvironment {
29 readonly environment: RelayClientEnvironmentRecord;
30 readonly availability: RelayEnvironmentAvailability;
31 readonly status: Option.Option<RelayEnvironmentStatusResponse>;
32 readonly error: Option.Option<ConnectionAttemptError>;
33}
34
35export interface RelayEnvironmentDiscoveryState {
36 readonly environments: ReadonlyMap<string, RelayDiscoveredEnvironment>;
37 readonly refreshing: boolean;
38 readonly offline: boolean;
39 readonly error: Option.Option<ConnectionAttemptError>;
40}
41
42export interface RelayEnvironmentDiscoveryService {
43 readonly state: SubscriptionRef.SubscriptionRef<RelayEnvironmentDiscoveryState>;
44 readonly refresh: Effect.Effect<void>;
45}
46
47export class RelayEnvironmentDiscovery extends Context.Service<
48 RelayEnvironmentDiscovery,
49 RelayEnvironmentDiscoveryService
50>()("@t3tools/client-runtime/relay/discovery/RelayEnvironmentDiscovery") {}
51
52export const EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE: RelayEnvironmentDiscoveryState = {
53 environments: new Map(),
54 refreshing: false,
55 offline: false,
56 error: Option.none(),
57};
58
59function 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
97function 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
107const 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
330export const relayEnvironmentDiscoveryLayer = Layer.effect(
331 RelayEnvironmentDiscovery,
332 makeRelayEnvironmentDiscovery(),
333);
packages/client-runtime/src/relay/index.ts created+3
...@@ -0,0 +1,3 @@
1export * from "./discovery.ts";
2export * from "./managedRelay.ts";
3export * from "./managedRelayState.ts";
packages/client-runtime/src/relay/managedRelay.test.ts created+515
...@@ -0,0 +1,515 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay";
3import { describe, expect, it } from "@effect/vitest";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as Tracer from "effect/Tracer";
9import * as TestClock from "effect/testing/TestClock";
10
11import {
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";
20import { remoteHttpClientLayer } from "../rpc/http.ts";
21
22function 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
42function 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
48describe("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 @@
1import {
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";
30import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";
31import { decodeRelayJwt } from "@t3tools/shared/relayJwt";
32import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
33import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
34import * as Clock from "effect/Clock";
35import * as Context from "effect/Context";
36import * as Data from "effect/Data";
37import * as Duration from "effect/Duration";
38import * as Effect from "effect/Effect";
39import * as Layer from "effect/Layer";
40import * as Option from "effect/Option";
41import * as Schema from "effect/Schema";
42import * as SynchronizedRef from "effect/SynchronizedRef";
43import { HttpClientError } from "effect/unstable/http";
44import type { HttpMethod } from "effect/unstable/http/HttpMethod";
45import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
46
47export interface ManagedRelayDpopProofInput {
48 readonly method: HttpMethod;
49 readonly url: string;
50 readonly accessToken?: string;
51}
52
53export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{
54 readonly cause: unknown;
55}> {}
56
57export class ManagedRelayRequestTimeoutError extends Data.TaggedError(
58 "ManagedRelayRequestTimeoutError",
59)<{
60 readonly message: string;
61}> {}
62
63type RelayHttpRequestError =
64 | RelayProtectedErrorType
65 | HttpClientError.HttpClientError
66 | Schema.SchemaError
67 | ManagedRelayRequestTimeoutError;
68
69export interface ManagedRelayDpopSignerShape {
70 readonly thumbprint: Effect.Effect<string, ManagedRelayDpopSignerError>;
71 readonly createProof: (
72 input: ManagedRelayDpopProofInput,
73 ) => Effect.Effect<string, ManagedRelayDpopSignerError>;
74}
75
76export class ManagedRelayDpopSigner extends Context.Service<
77 ManagedRelayDpopSigner,
78 ManagedRelayDpopSignerShape
79>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {}
80
81export 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
88export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000;
89
90export 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
100export 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
106export interface ManagedRelayAuthorization {
107 readonly accessToken: string;
108 readonly proof: string;
109 readonly thumbprint: string;
110}
111
112export interface ManagedRelayClientLayerOptions {
113 readonly relayUrl: string;
114 readonly clientId: RelayPublicClientId;
115 readonly accessTokenStore?: ManagedRelayAccessTokenStore;
116}
117
118export 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
164export class ManagedRelayClient extends Context.Service<
165 ManagedRelayClient,
166 ManagedRelayClientShape
167>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {}
168
169const isRelayProtectedError = Schema.is(RelayProtectedError);
170
171function relayClientError(message: string, cause?: RelayHttpRequestError): ManagedRelayClientError {
172 return new ManagedRelayClientError({
173 message,
174 ...(cause === undefined ? {} : { cause }),
175 });
176}
177
178function relayLocalError(
179 message: string,
180 cause: ManagedRelayDpopSignerError,
181): ManagedRelayClientError {
182 return new ManagedRelayClientError({ message, cause });
183}
184
185function 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
194function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean {
195 return (
196 error.relayError?._tag === "RelayAuthInvalidError" &&
197 error.relayError.reason === "invalid_bearer"
198 );
199}
200
201function 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
219function 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
240function 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
250function bearerHeaders(clerkToken: string) {
251 return { authorization: `Bearer ${clerkToken}` };
252}
253
254function dpopHeaders(authorization: ManagedRelayAuthorization) {
255 return {
256 authorization: `DPoP ${authorization.accessToken}`,
257 dpop: authorization.proof,
258 };
259}
260
261function 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
286export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import type {
3 RelayClientDeviceRecord,
4 RelayClientEnvironmentRecord,
5 RelayEnvironmentStatusResponse,
6} from "@t3tools/contracts/relay";
7import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as Stream from "effect/Stream";
12import { Atom, AtomRegistry } from "effect/unstable/reactivity";
13import { afterEach, vi } from "vite-plus/test";
14
15import {
16 ManagedRelayClient,
17 ManagedRelayClientError,
18 type ManagedRelayClientShape,
19} from "./managedRelay.ts";
20import {
21 createManagedRelayQueryManager,
22 createManagedRelaySession,
23 managedRelayAccountChanges,
24 type ManagedRelayQueryEvent,
25 managedRelaySessionAtom,
26 readManagedRelaySnapshotState,
27 setManagedRelaySession,
28 waitForManagedRelayClerkToken,
29} from "./managedRelayState.ts";
30
31let registry = AtomRegistry.make();
32
33const 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
44const 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
63function resetRegistry() {
64 registry.dispose();
65 registry = AtomRegistry.make();
66}
67
68function 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
100function setSession() {
101 setManagedRelaySession(registry, {
102 accountId: "account-1",
103 readClerkToken: () => Promise.resolve("clerk-token"),
104 });
105}
106
107function 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
113describe("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 @@
1import type {
2 RelayClientEnvironmentRecord,
3 RelayEnvironmentStatusResponse,
4} from "@t3tools/contracts/relay";
5import {
6 RelayEnvironmentConnectScope,
7 RelayEnvironmentStatusScope,
8} from "@t3tools/contracts/relay";
9import { decodeRelayJwt } from "@t3tools/shared/relayJwt";
10import * as Cause from "effect/Cause";
11import * as Clock from "effect/Clock";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import * as Option from "effect/Option";
15import * as Stream from "effect/Stream";
16import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
17
18import { findErrorTraceId } from "../errors/errorTrace.ts";
19import { ManagedRelayClient } from "./managedRelay.ts";
20
21const DEFAULT_STALE_TIME_MS = 15_000;
22const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
23const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000;
24
25export interface ManagedRelaySession {
26 readonly accountId: string;
27 readonly readClerkToken: () => Effect.Effect<string | null, ManagedRelaySessionError>;
28}
29
30export interface ManagedRelaySessionInput {
31 readonly accountId: string;
32 readonly readClerkToken: () => Promise<string | null>;
33}
34
35interface ManagedRelaySessionControl {
36 readonly updateReadClerkToken: (
37 readClerkToken: ManagedRelaySessionInput["readClerkToken"],
38 ) => void;
39}
40
41export interface ManagedRelaySnapshotState<A> {
42 readonly data: A | null;
43 readonly error: string | null;
44 readonly errorTraceId: string | null;
45 readonly isPending: boolean;
46}
47
48export 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
58export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{
59 readonly message: string;
60 readonly cause?: unknown;
61}> {}
62
63export class ManagedRelaySnapshotError extends Data.TaggedError("ManagedRelaySnapshotError")<{
64 readonly message: string;
65}> {}
66
67export const managedRelaySessionAtom = Atom.make<ManagedRelaySession | null>(null).pipe(
68 Atom.keepAlive,
69 Atom.withLabel("managed-relay:session"),
70);
71
72const managedRelaySessionControls = new WeakMap<ManagedRelaySession, ManagedRelaySessionControl>();
73
74export 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
142export 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
165export 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
175function 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
191export 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
221function 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
236function statusKey(input: {
237 readonly accountId: string;
238 readonly environment: RelayClientEnvironmentRecord;
239}): string {
240 return JSON.stringify(input);
241}
242
243function 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
253function 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
264function 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
292export 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
310export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Schema from "effect/Schema";
6import * as TestClock from "effect/testing/TestClock";
7
8import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
9import {
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
23const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
24
25type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit];
26
27const 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
42const 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
52const provideRemoteHttp = (fetchFn: typeof fetch) => Effect.provide(remoteHttpClientLayer(fetchFn));
53
54const 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
91describe("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 @@
1import {
2 AuthAccessTokenType,
3 type AuthClientPresentationMetadata,
4 AuthEnvironmentBootstrapTokenType,
5 AuthTokenExchangeGrantType,
6 type AuthEnvironmentScope,
7 EnvironmentHttpApi,
8 EnvironmentHttpCommonError,
9} from "@t3tools/contracts";
10import type {
11 EnvironmentAuthInvalidError,
12 EnvironmentInternalError,
13 EnvironmentOperationForbiddenError,
14 EnvironmentRequestInvalidError,
15 EnvironmentScopeRequiredError,
16} from "@t3tools/contracts";
17import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
18import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
19import * as Data from "effect/Data";
20import * as Duration from "effect/Duration";
21import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
23import * as Option from "effect/Option";
24import * as Schema from "effect/Schema";
25import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http";
26import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
27
28const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000;
29const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
30
31export 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
39const 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
47const 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
55export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(
56 "RemoteEnvironmentAuthFetchError",
57)<{
58 readonly message: string;
59 readonly cause: unknown;
60}> {}
61
62export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError(
63 "RemoteEnvironmentAuthInvalidJsonError",
64)<{
65 readonly message: string;
66 readonly cause: unknown;
67}> {}
68
69export 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
85export 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
101export type RemoteEnvironmentAuthError =
102 | EnvironmentRequestInvalidError
103 | EnvironmentAuthInvalidError
104 | EnvironmentScopeRequiredError
105 | EnvironmentOperationForbiddenError
106 | EnvironmentInternalError
107 | RemoteEnvironmentAuthFetchError
108 | RemoteEnvironmentAuthInvalidJsonError
109 | RemoteEnvironmentAuthUndeclaredStatusError
110 | RemoteEnvironmentAuthTimeoutError;
111
112export 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
120const 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
160const 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
176export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) =>
177 HttpApiClient.make(EnvironmentHttpApi, {
178 baseUrl: remoteApiBaseUrl(httpBaseUrl),
179 });
180
181export 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
210export 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
237export 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
256export 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
277export 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
288export 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
307export 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
328export 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
350export 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 @@
1import {
2 EnvironmentId,
3 type RelayClientInstallProgressEvent,
4 WS_METHODS,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "@effect/vitest";
7import * as Cause from "effect/Cause";
8import * as Effect from "effect/Effect";
9import * as Exit from "effect/Exit";
10import * as Fiber from "effect/Fiber";
11import * as Option from "effect/Option";
12import * as Queue from "effect/Queue";
13import * as Ref from "effect/Ref";
14import * as Stream from "effect/Stream";
15import * as SubscriptionRef from "effect/SubscriptionRef";
16import * as TestClock from "effect/testing/TestClock";
17import { RpcClientError } from "effect/unstable/rpc";
18
19import {
20 AVAILABLE_CONNECTION_STATE,
21 PrimaryConnectionTarget,
22 type PreparedConnection,
23 type SupervisorConnectionState,
24} from "../connection/model.ts";
25import {
26 EnvironmentSupervisor,
27 type EnvironmentSupervisorService,
28} from "../connection/supervisor.ts";
29import type { RpcSession } from "../rpc/session.ts";
30import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
31import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts";
32
33const 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
40const INSTALL_CHECKING: RelayClientInstallProgressEvent = {
41 type: "progress",
42 stage: "checking",
43};
44const INSTALL_DOWNLOADING: RelayClientInstallProgressEvent = {
45 type: "progress",
46 stage: "downloading",
47};
48
49function 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
59const 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
80describe("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 @@
1import { ORCHESTRATION_WS_METHODS, type ServerConfig, WS_METHODS } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import * as Context from "effect/Context";
4import type * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import * as SubscriptionRef from "effect/SubscriptionRef";
10import { RpcClientError } from "effect/unstable/rpc";
11
12import type { ConnectionAttemptError } from "../connection/model.ts";
13import { EnvironmentSupervisor } from "../connection/supervisor.ts";
14import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
15
16export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass<EnvironmentRpcUnavailableError>()(
17 "EnvironmentRpcUnavailableError",
18 {
19 environmentId: Schema.String,
20 message: Schema.String,
21 },
22) {}
23
24export interface EnvironmentRpcRequestObservation {
25 readonly environmentId: string;
26 readonly method: string;
27}
28
29export 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
39export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string;
40type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];
41
42export 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
56export type EnvironmentStreamCommandRpcTag =
57 | typeof WS_METHODS.cloudInstallRelayClient
58 | typeof WS_METHODS.gitRunStackedAction;
59
60export type EnvironmentStreamRpcTag =
61 | EnvironmentSubscriptionRpcTag
62 | EnvironmentStreamCommandRpcTag;
63
64export type EnvironmentUnaryRpcTag = Exclude<EnvironmentRpcTag, EnvironmentStreamRpcTag>;
65const isRpcClientError = Schema.is(RpcClientError.RpcClientError);
66
67export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0];
68
69export type EnvironmentRpcSuccess<TTag extends EnvironmentUnaryRpcTag> =
70 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer A, any, any>
71 ? A
72 : never;
73
74export type EnvironmentRpcFailure<TTag extends EnvironmentUnaryRpcTag> =
75 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<any, infer E, any>
76 ? E
77 : never;
78
79export type EnvironmentRpcStreamValue<TTag extends EnvironmentStreamRpcTag> =
80 RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer A, any, any>
81 ? A
82 : never;
83
84export type EnvironmentRpcStreamFailure<TTag extends EnvironmentStreamRpcTag> =
85 RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<any, infer E, any>
86 ? E
87 : never;
88
89const 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
107export 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
127export 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
151export 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
240export 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 @@
1import {
2 EnvironmentHttpApi,
3 EnvironmentHttpCommonError,
4 type EnvironmentAuthInvalidError,
5 type EnvironmentInternalError,
6 type EnvironmentOperationForbiddenError,
7 type EnvironmentRequestInvalidError,
8 type EnvironmentScopeRequiredError,
9} from "@t3tools/contracts";
10import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
11import * as Data from "effect/Data";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Schema from "effect/Schema";
17import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http";
18import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
19
20const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
21
22export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(
23 "RemoteEnvironmentAuthFetchError",
24)<{
25 readonly message: string;
26 readonly cause: unknown;
27}> {}
28
29export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError(
30 "RemoteEnvironmentAuthInvalidJsonError",
31)<{
32 readonly message: string;
33 readonly cause: unknown;
34}> {}
35
36export 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
52export 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
68export type RemoteEnvironmentRequestError =
69 | EnvironmentRequestInvalidError
70 | EnvironmentAuthInvalidError
71 | EnvironmentScopeRequiredError
72 | EnvironmentOperationForbiddenError
73 | EnvironmentInternalError
74 | RemoteEnvironmentAuthFetchError
75 | RemoteEnvironmentAuthInvalidJsonError
76 | RemoteEnvironmentAuthUndeclaredStatusError
77 | RemoteEnvironmentAuthTimeoutError;
78
79export 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
87const 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
95export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) =>
96 HttpApiClient.make(EnvironmentHttpApi, {
97 baseUrl: remoteApiBaseUrl(httpBaseUrl),
98 });
99
100const 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
140export 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 @@
1export * from "./client.ts";
2export * from "./http.ts";
3export * from "./protocol.ts";
4export * from "./session.ts";
packages/client-runtime/src/rpc/protocol.ts created+8
...@@ -0,0 +1,8 @@
1import { WsRpcGroup } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import { RpcClient } from "effect/unstable/rpc";
4
5export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
6type RpcClientFactory = typeof makeWsRpcProtocolClient;
7export 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 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,
4 ServerConfig,
5 type ServerConfig as ServerConfigType,
6 WS_METHODS,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as Layer from "effect/Layer";
12import * as Schema from "effect/Schema";
13import * as TestClock from "effect/testing/TestClock";
14import * as Socket from "effect/unstable/socket/Socket";
15
16import {
17 ConnectionTransientError,
18 PrimaryConnectionTarget,
19 type PreparedConnection,
20} from "../connection/model.ts";
21import { RpcSessionFactory, rpcSessionFactoryLayer } from "./session.ts";
22
23type SocketEventType = "open" | "message" | "close" | "error";
24type SocketEvent = {
25 readonly code?: number;
26 readonly data?: unknown;
27 readonly reason?: string;
28 readonly type: SocketEventType;
29};
30type SocketListener = (event: SocketEvent) => void;
31
32class 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
85const 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
92const 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
101const 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
135const RpcRequest = Schema.TaggedStruct("Request", {
136 id: Schema.String,
137 payload: Schema.Unknown,
138 tag: Schema.String,
139});
140const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString);
141const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest);
142const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
143const encodeServerConfig = Schema.encodeSync(ServerConfig);
144
145const 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
157const 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
170const 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
183const 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
204describe("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 @@
1import { type ServerConfig, WS_METHODS } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schedule from "effect/Schedule";
7import type * as Scope from "effect/Scope";
8import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
9import * as Socket from "effect/unstable/socket/Socket";
10
11import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts";
12import type {
13 ConnectionAttemptError,
14 ConnectionTransientError,
15 PreparedConnection,
16} from "../connection/model.ts";
17import {
18 ConnectionBlockedError,
19 ConnectionTransientError as ConnectionTransientErrorClass,
20} from "../connection/model.ts";
21
22const SOCKET_OPEN_TIMEOUT = "15 seconds";
23
24export 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
32export 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
41type InitialConfigError = Effect.Error<
42 ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverGetConfig]>
43>;
44
45function 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
66export 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 @@
1import {
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
11export function scopeProjectRef(
12 environmentId: EnvironmentIdType,
13 projectId: ProjectIdType,
14): ScopedProjectRef {
15 return { environmentId, projectId };
16}
17
18export function scopeThreadRef(
19 environmentId: EnvironmentIdType,
20 threadId: ThreadId,
21): ScopedThreadRef {
22 return { environmentId, threadId };
23}
24
25export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string {
26 const localId = "projectId" in ref ? ref.projectId : ref.threadId;
27 return `${ref.environmentId}:${localId}`;
28}
29
30export function scopedProjectKey(ref: ScopedProjectRef): string {
31 return scopedRefKey(ref);
32}
33
34export function scopedThreadKey(ref: ScopedThreadRef): string {
35 return scopedRefKey(ref);
36}
37
38function 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
49export 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
60export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts";
5
6import { applyShellStreamEvent } from "./shellSnapshotReducer.ts";
7
8const baseSnapshot: OrchestrationShellSnapshot = {
9 snapshotSequence: 0,
10 projects: [],
11 threads: [],
12 updatedAt: "2026-04-01T00:00:00.000Z",
13};
14
15const 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
26const 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
46describe("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 @@
1import * as Arr from "effect/Array";
2import 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 */
12export 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 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2import { afterEach, describe, expect, it } from "vite-plus/test";
3
4import {
5 EnvironmentId,
6 ProjectId,
7 ProviderInstanceId,
8 ThreadId,
9 type OrchestrationShellSnapshot,
10} from "@t3tools/contracts";
11
12import { createShellSnapshotManager } from "./shellSnapshotState.ts";
13
14let atomRegistry = AtomRegistry.make();
15
16function resetAtomRegistry() {
17 atomRegistry.dispose();
18 atomRegistry = AtomRegistry.make();
19}
20
21const 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
59const TARGET = { environmentId: EnvironmentId.make("env-local") } as const;
60
61describe("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 @@
1import type {
2 EnvironmentId,
3 OrchestrationShellSnapshot,
4 OrchestrationShellStreamEvent,
5} from "@t3tools/contracts";
6import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
7
8import { applyShellStreamEvent } from "./shellSnapshotReducer.ts";
9
10export interface ShellSnapshotState {
11 readonly data: OrchestrationShellSnapshot | null;
12 readonly error: string | null;
13 readonly isPending: boolean;
14}
15
16export interface ShellSnapshotTarget {
17 readonly environmentId: EnvironmentId | null;
18}
19
20export const EMPTY_SHELL_SNAPSHOT_STATE = Object.freeze<ShellSnapshotState>({
21 data: null,
22 error: null,
23 isPending: false,
24});
25
26const INITIAL_SHELL_SNAPSHOT_STATE = Object.freeze<ShellSnapshotState>({
27 data: null,
28 error: null,
29 isPending: true,
30});
31
32const knownShellSnapshotKeys = new Set<string>();
33
34export 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
42export const EMPTY_SHELL_SNAPSHOT_ATOM = Atom.make(EMPTY_SHELL_SNAPSHOT_STATE).pipe(
43 Atom.keepAlive,
44 Atom.withLabel("shell-snapshot:null"),
45);
46
47export function getShellSnapshotTargetKey(target: ShellSnapshotTarget): string | null {
48 return target.environmentId;
49}
50
51export interface ShellSnapshotManagerConfig {
52 readonly getRegistry: () => AtomRegistry.AtomRegistry;
53}
54
55export 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 @@
1import type {
2 EnvironmentId,
3 OrchestrationProjectShell,
4 OrchestrationShellSnapshot,
5 OrchestrationThreadShell,
6 ThreadId,
7} from "@t3tools/contracts";
8
9export interface EnvironmentScopedProjectShell extends OrchestrationProjectShell {
10 readonly environmentId: EnvironmentId;
11}
12
13export interface EnvironmentScopedThreadShell extends OrchestrationThreadShell {
14 readonly environmentId: EnvironmentId;
15}
16
17export function scopeProjectShell(
18 environmentId: EnvironmentId,
19 project: OrchestrationProjectShell,
20): EnvironmentScopedProjectShell {
21 return { ...project, environmentId };
22}
23
24export function scopeThreadShell(
25 environmentId: EnvironmentId,
26 thread: OrchestrationThreadShell,
27): EnvironmentScopedThreadShell {
28 return { ...thread, environmentId };
29}
30
31export 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 @@
1import { assert, beforeEach, it } from "vite-plus/test";
2import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
3import * as Option from "effect/Option";
4import { AtomRegistry } from "effect/unstable/reactivity";
5
6import {
7 EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
8 createSourceControlDiscoveryManager,
9} from "./sourceControlDiscoveryState.ts";
10
11const EMPTY_RESULT: SourceControlDiscoveryResult = {
12 versionControlSystems: [],
13 sourceControlProviders: [],
14};
15
16const 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
46function unresolvedDiscovery() {
47 throw new Error("Discovery resolver was not initialized.");
48}
49
50let registry = AtomRegistry.make();
51
52const noop = () => undefined;
53
54beforeEach(() => {
55 registry.dispose();
56 registry = AtomRegistry.make();
57});
58
59function flushAsyncWork(): Promise<void> {
60 return Promise.resolve().then(() => undefined);
61}
62
63it("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
83it("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
119it("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
146it("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
164it("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
190it("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
236it("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
261it("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 @@
1import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
4
5/* --- Types ---------------------------------------------------------- */
6
7export interface SourceControlDiscoveryState {
8 readonly data: SourceControlDiscoveryResult | null;
9 readonly error: string | null;
10 readonly isPending: boolean;
11}
12
13export interface SourceControlDiscoveryTarget<TKey extends string = string> {
14 readonly key: TKey | null;
15}
16
17export interface SourceControlDiscoveryClient {
18 readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;
19}
20
21interface WatchedEntry {
22 refCount: number;
23 teardown: () => void;
24}
25
26/* --- Constants ------------------------------------------------------ */
27
28export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({
29 data: null,
30 error: null,
31 isPending: false,
32});
33
34const INITIAL_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({
35 data: null,
36 error: null,
37 isPending: true,
38});
39
40/* --- Atoms ---------------------------------------------------------- */
41
42const knownSourceControlDiscoveryKeys = new Set<string>();
43
44export 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
52export 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
58export 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
67export 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
91const NOOP: () => void = () => undefined;
92const DEFAULT_STALE_TIME_MS = 30_000;
93const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
94
95export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { expect, it } from "vite-plus/test";
3
4import {
5 makeArchivedThreadsEnvironmentKey,
6 parseArchivedThreadsEnvironmentKey,
7} from "./archivedThreads.ts";
8
9it("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 @@
1import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
2import * as Arr from "effect/Array";
3import { pipe } from "effect/Function";
4import * as Order from "effect/Order";
5
6export interface ArchivedSnapshotEntry {
7 readonly environmentId: EnvironmentId;
8 readonly snapshot: OrchestrationShellSnapshot;
9}
10
11const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f";
12const environmentIdOrder = Order.String as Order.Order<EnvironmentId>;
13
14export 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
22export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
3import * as Layer from "effect/Layer";
4import { Atom } from "effect/unstable/reactivity";
5
6import type { EnvironmentRegistry } from "../connection/registry.ts";
7import { createAssetEnvironmentAtoms } from "./assets.ts";
8
9describe("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 @@
1import { EnvironmentId, type AssetResource, WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";
3
4import type { EnvironmentRegistry } from "../connection/registry.ts";
5import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
6
7const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
8const ASSET_URL_STALE_TIME_MS = 5 * 60_000;
9const ASSET_URL_IDLE_TTL_MS = 60 * 60_000;
10
11export 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
19export 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 @@
1import { AuthSessionId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
4
5import { applyAuthAccessStreamEvent, EMPTY_AUTH_ACCESS_SNAPSHOT } from "./auth.ts";
6
7describe("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 @@
1import type {
2 AuthAccessSnapshot,
3 AuthAccessStreamEvent,
4 AuthAccessStreamSnapshotEvent,
5} from "@t3tools/contracts";
6import { WS_METHODS } from "@t3tools/contracts";
7import * as Stream from "effect/Stream";
8import { Atom } from "effect/unstable/reactivity";
9
10import type { EnvironmentRegistry } from "../connection/registry.ts";
11import { subscribe } from "../rpc/client.ts";
12import { createEnvironmentSubscriptionAtomFamily } from "./runtime.ts";
13
14export const EMPTY_AUTH_ACCESS_SNAPSHOT: AuthAccessSnapshot = {
15 pairingLinks: [],
16 clientSessions: [],
17};
18
19function 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
28export 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
64export 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
78export 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 @@
1import type {
2 EnvironmentId,
3 OrchestrationGetFullThreadDiffResult,
4 OrchestrationGetTurnDiffResult,
5 ThreadId,
6} from "@t3tools/contracts";
7
8export type CheckpointDiffResult =
9 | OrchestrationGetTurnDiffResult
10 | OrchestrationGetFullThreadDiffResult;
11
12export interface CheckpointDiffState {
13 readonly data: CheckpointDiffResult | null;
14 readonly error: string | null;
15 readonly isPending: boolean;
16}
17
18export 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 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3export interface ComposerPathSearchEntry {
4 readonly path: string;
5 readonly kind: "file" | "directory";
6 readonly parentPath?: string;
7}
8
9export interface ComposerPathSearchState {
10 readonly entries: ReadonlyArray<ComposerPathSearchEntry>;
11 readonly isPending: boolean;
12 readonly error: string | null;
13}
14
15export 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 @@
1import type { EnvironmentId as EnvironmentIdType } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5import * as SubscriptionRef from "effect/SubscriptionRef";
6import { AsyncResult, Atom } from "effect/unstable/reactivity";
7
8import { EnvironmentRegistry, type EnvironmentRegistryService } from "../connection/registry.ts";
9import type { ConnectionCatalogEntry } from "../connection/catalog.ts";
10import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts";
11import { EnvironmentSupervisor } from "../connection/supervisor.ts";
12import {
13 createAtomCommandScheduler,
14 createRuntimeCommand,
15 followStreamInEnvironment,
16} from "./runtime.ts";
17
18export interface EnvironmentCatalogState {
19 readonly isReady: boolean;
20 readonly entries: ReadonlyMap<EnvironmentIdType, ConnectionCatalogEntry>;
21}
22
23export const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({
24 isReady: false,
25 entries: new Map(),
26});
27
28export 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 @@
1import {
2 EnvironmentId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 type OrchestrationShellSnapshot,
7 type OrchestrationThread,
8} from "@t3tools/contracts";
9import { describe, expect, it } from "@effect/vitest";
10import * as Option from "effect/Option";
11import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
12
13import { PrimaryConnectionTarget } from "../connection/model.ts";
14import type { EnvironmentShellState } from "./shell.ts";
15import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";
16import { createEnvironmentProjectAtoms } from "./projectEntities.ts";
17import { createEnvironmentSnapshotAtom } from "./snapshots.ts";
18import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";
19import { mergeEnvironmentThread } from "./threadDetail.ts";
20import { createEnvironmentThreadShellAtoms } from "./threadShell.ts";
21
22const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
23const PROJECT_ID = ProjectId.make("project-1");
24const OTHER_PROJECT_ID = ProjectId.make("project-2");
25const THREAD_ID = ThreadId.make("thread-1");
26const OTHER_THREAD_ID = ThreadId.make("thread-2");
27
28const 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
48const 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
84function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState {
85 return {
86 snapshot: Option.some(snapshot),
87 status: "live",
88 error: Option.none(),
89 };
90}
91
92function 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
139describe("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 @@
1import {
2 EnvironmentId,
3 ProjectId,
4 ThreadId,
5 type ScopedProjectRef,
6 type ScopedThreadRef,
7} from "@t3tools/contracts";
8
9export function projectKey(ref: ScopedProjectRef): string {
10 return `${ref.environmentId}\u0000${ref.projectId}`;
11}
12
13export function threadKey(ref: ScopedThreadRef): string {
14 return `${ref.environmentId}\u0000${ref.threadId}`;
15}
16
17export function projectRefCollectionKey(refs: ReadonlyArray<ScopedProjectRef>): string {
18 return JSON.stringify(refs.map((ref) => [ref.environmentId, ref.projectId]));
19}
20
21export 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
32export 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
40export 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
51export 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
65export 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
79export 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 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";
3
4import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
5import type { EnvironmentRegistry } from "../connection/registry.ts";
6
7export 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 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";
3
4import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
5import type { EnvironmentRegistry } from "../connection/registry.ts";
6import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts";
7
8export 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 @@
1import type {
2 GitRunStackedActionInput,
3 GitRunStackedActionResult,
4 GitStackedAction,
5 VcsStatusResult,
6} from "@t3tools/contracts";
7import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
8
9export type GitActionIconName = "commit" | "push" | "pr";
10
11export type GitDialogAction = "commit" | "push" | "create_pr";
12
13export 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
22export 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
30export interface DefaultBranchActionDialogCopy {
31 title: string;
32 description: string;
33 continueLabel: string;
34}
35
36export type DefaultBranchConfirmableAction =
37 | "push"
38 | "create_pr"
39 | "commit_push"
40 | "commit_push_pr";
41
42export type GitActionRequestInput = Pick<
43 GitRunStackedActionInput,
44 "action" | "commitMessage" | "featureBranch" | "filePaths"
45>;
46
47export 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
85export 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
151export 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
286export 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
350export 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
363export 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
400export 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
412export 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 @@
1import type {
2 EnvironmentId,
3 OrchestrationMessage,
4 OrchestrationProjectShell,
5 OrchestrationShellSnapshot,
6 OrchestrationThread,
7 OrchestrationThreadShell,
8 ThreadId,
9} from "@t3tools/contracts";
10
11export interface EnvironmentProject extends OrchestrationProjectShell {
12 readonly environmentId: EnvironmentId;
13}
14
15export interface EnvironmentThreadShell extends OrchestrationThreadShell {
16 readonly environmentId: EnvironmentId;
17}
18
19export type EnvironmentMessage = OrchestrationMessage;
20
21export interface EnvironmentThread extends OrchestrationThread {
22 readonly environmentId: EnvironmentId;
23}
24
25export function scopeProject(
26 environmentId: EnvironmentId,
27 project: OrchestrationProjectShell,
28): EnvironmentProject {
29 return { ...project, environmentId };
30}
31
32export function scopeThreadShell(
33 environmentId: EnvironmentId,
34 thread: OrchestrationThreadShell,
35): EnvironmentThreadShell {
36 return { ...thread, environmentId };
37}
38
39export function scopeThread(
40 environmentId: EnvironmentId,
41 thread: OrchestrationThread,
42): EnvironmentThread {
43 return { ...thread, environmentId };
44}
45
46export 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 @@
1import { ORCHESTRATION_WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";
3
4import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
5import type { EnvironmentRegistry } from "../connection/registry.ts";
6
7export 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 @@
1import type { EnvironmentId, ServerConfig } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import { AsyncResult, Atom } from "effect/unstable/reactivity";
4
5import { AVAILABLE_CONNECTION_STATE, type SupervisorConnectionState } from "../connection/model.ts";
6import {
7 presentEnvironmentConnection,
8 type EnvironmentPresentation,
9} from "../connection/presentation.ts";
10import type { EnvironmentCatalogState } from "./connections.ts";
11
12function 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
24export 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 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";
3
4import type { EnvironmentRegistry } from "../connection/registry.ts";
5import {
6 createAtomCommandScheduler,
7 createEnvironmentRpcCommand,
8 createEnvironmentRpcQueryAtomFamily,
9 createEnvironmentRpcSubscriptionAtomFamily,
10} from "./runtime.ts";
11
12export 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 @@
1import { type EnvironmentId, type ProjectReadFileResult, WS_METHODS } from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";
3import { Atom } from "effect/unstable/reactivity";
4
5import {
6 createAtomCommandScheduler,
7 createEnvironmentCommand,
8 createEnvironmentRpcCommand,
9 createEnvironmentRpcQueryAtomFamily,
10} from "./runtime.ts";
11import {
12 type CreateProjectInput,
13 type DeleteProjectInput,
14 type UpdateProjectInput,
15 createProject,
16 deleteProject,
17 updateProject,
18} from "../operations/commands.ts";
19import type { EnvironmentRegistry } from "../connection/registry.ts";
20
21export type {
22 CreateProjectInput,
23 DeleteProjectInput,
24 UpdateProjectInput,
25} from "../operations/commands.ts";
26
27export interface OptimisticProjectFile {
28 readonly data: ProjectReadFileResult;
29 readonly confirmedAgainst: object | null | undefined;
30}
31
32export interface OptimisticProjectFileTarget {
33 readonly environmentId: EnvironmentId;
34 readonly cwd: string;
35 readonly relativePath: string;
36}
37
38function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string {
39 return JSON.stringify([target.environmentId, target.cwd, target.relativePath]);
40}
41
42export 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 @@
1import type {
2 EnvironmentId,
3 OrchestrationProjectShell,
4 OrchestrationShellSnapshot,
5 ProjectId,
6 ScopedProjectRef,
7} from "@t3tools/contracts";
8import { Atom } from "effect/unstable/reactivity";
9
10import type { EnvironmentProject } from "./models.ts";
11import { scopeProject } from "./models.ts";
12import type { EnvironmentCatalogState } from "./connections.ts";
13import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts";
14
15const EMPTY_PROJECTS: ReadonlyArray<OrchestrationProjectShell> = Object.freeze([]);
16const EMPTY_PROJECT_INDEX: ReadonlyMap<ProjectId, OrchestrationProjectShell> = new Map();
17
18export 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 @@
1import {
2 isExplicitRelativePath,
3 isUncPath,
4 isWindowsAbsolutePath,
5 isWindowsDrivePath,
6} from "@t3tools/shared/path";
7
8const isWindowsPlatform = (platform: string): boolean => {
9 return /^win(dows)?/i.test(platform);
10};
11
12function isRootPath(value: string): boolean {
13 return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
14}
15
16function 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
26function 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
40function preferredPathSeparator(value: string): "/" | "\\" {
41 const absolutePathKind = getAbsolutePathKind(value);
42 if (absolutePathKind === "windows") return "\\";
43 if (absolutePathKind === "unix") return "/";
44 return value.includes("\\") ? "\\" : "/";
45}
46
47export function hasTrailingPathSeparator(value: string): boolean {
48 return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value);
49}
50
51export { isExplicitRelativePath as isExplicitRelativeProjectPath };
52
53function splitPathSegments(value: string, separator: "/" | "\\"): string[] {
54 return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean);
55}
56
57function getLastPathSeparatorIndex(value: string): number {
58 if (getAbsolutePathKind(value) === "unix") {
59 return value.lastIndexOf("/");
60 }
61 return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
62}
63
64function 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
94export 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
107export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean {
108 return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform);
109}
110
111export function normalizeProjectPathForDispatch(value: string): string {
112 return trimTrailingPathSeparators(value.trim());
113}
114
115export 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
142export 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
150export 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
164export 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
174export function appendBrowsePathSegment(currentPath: string, segment: string): string {
175 const separator = preferredPathSeparator(currentPath);
176 return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`;
177}
178
179export function getBrowseLeafPathSegment(currentPath: string): string {
180 const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
181 return currentPath.slice(lastSeparatorIndex + 1);
182}
183
184export 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
192export 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
200export 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
219export function canNavigateUp(currentPath: string): boolean {
220 return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null;
221}
222
223export * from "./projectCommands.ts";
224export * from "./projectEntities.ts";
packages/client-runtime/src/state/relayDiscovery.ts created+42
...@@ -0,0 +1,42 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Stream from "effect/Stream";
4import * as SubscriptionRef from "effect/SubscriptionRef";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";
6
7import {
8 EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE,
9 RelayEnvironmentDiscovery,
10} from "../relay/discovery.ts";
11import { createRuntimeCommand } from "./runtime.ts";
12
13export 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 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";
3
4import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
5import type { EnvironmentRegistry } from "../connection/registry.ts";
6
7export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Fiber from "effect/Fiber";
7import * as Latch from "effect/Latch";
8import * as Layer from "effect/Layer";
9import * as Stream from "effect/Stream";
10import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
11
12import {
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
26describe("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
64describe("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
142describe("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
165describe("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
274describe("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
300describe("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 @@
1import { EnvironmentId, type EnvironmentId as EnvironmentIdType } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Option from "effect/Option";
6import * as Result from "effect/Result";
7import * as Stream from "effect/Stream";
8import * as SubscriptionRef from "effect/SubscriptionRef";
9import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
10
11import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts";
12import {
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";
23import { EnvironmentSupervisor } from "../connection/supervisor.ts";
24
25interface 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
35interface 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
46interface 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
52export type SettledAsyncResult<A, E> = AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>;
53
54export type AtomCommandResult<A, E> = SettledAsyncResult<A, E>;
55
56export type AtomCommandSuccess<R> = R extends AtomCommandResult<infer A, infer _E> ? A : never;
57
58export type AtomCommandFailure<R> = R extends AtomCommandResult<infer _A, infer E> ? E : never;
59
60export interface AtomCommandOptions {
61 readonly label?: string;
62 readonly reportFailure?: boolean;
63 readonly reportDefect?: boolean;
64}
65
66export interface AtomCommandReporter {
67 readonly warn: (message: string, cause: Cause.Cause<unknown>) => void;
68 readonly error: (message: string, cause: Cause.Cause<unknown>) => void;
69}
70
71export interface AtomCommand<W, A, E> {
72 readonly label: string;
73 readonly run: (registry: AtomRegistry.AtomRegistry, input: W) => Promise<AtomCommandResult<A, E>>;
74}
75
76export 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
88interface AtomCommandSchedulerState {
89 readonly serial: Map<string, Promise<unknown>>;
90 readonly singleFlight: Map<string, Promise<unknown>>;
91 readonly latest: Map<string, AtomCommandLatestLane>;
92}
93
94interface AtomCommandLatestBatch {
95 execute: () => Promise<AtomCommandResult<unknown, unknown>>;
96 readonly resolve: Array<(result: AtomCommandResult<unknown, unknown>) => void>;
97}
98
99interface AtomCommandLatestLane {
100 running: boolean;
101 pending: AtomCommandLatestBatch | undefined;
102}
103
104export 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
113async 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
123export 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
245export 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
257export 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
266export function isAtomCommandInterrupted(result: AtomCommandResult<unknown, unknown>): boolean {
267 return result._tag === "Failure" && Cause.hasInterruptsOnly(result.cause);
268}
269
270export function squashAtomCommandFailure(result: {
271 readonly cause: Cause.Cause<unknown>;
272}): unknown {
273 return Cause.squash(result.cause);
274}
275
276export 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
286export 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
296export 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
313export 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
338export 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
363export 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
382export 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
392export function environmentRpcKey<Input>(target: {
393 readonly environmentId: EnvironmentIdType;
394 readonly input: Input;
395}): string {
396 return JSON.stringify([target.environmentId, target.input]);
397}
398
399function 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
410export 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
423export 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
436export 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
447function 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
504export 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
521export 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
533function 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
556export 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
577export 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
609export 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
629export 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 @@
1import { type ServerConfig, type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Option from "effect/Option";
4
5import { applyServerConfigProjection, projectServerWelcome } from "./server.ts";
6
7const CONFIG = {
8 availableEditors: [],
9 issues: [],
10 keybindings: {},
11 keybindingsConfigPath: null,
12 observability: null,
13 providers: [],
14 settings: {},
15} as unknown as ServerConfig;
16
17describe("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 @@
1import {
2 type EnvironmentId,
3 type ServerConfig,
4 type ServerConfigStreamEvent,
5 type ServerLifecycleWelcomePayload,
6 WS_METHODS,
7} from "@t3tools/contracts";
8import * as Option from "effect/Option";
9import * as Stream from "effect/Stream";
10import { AsyncResult, Atom } from "effect/unstable/reactivity";
11
12import {
13 createAtomCommandScheduler,
14 createEnvironmentRpcCommand,
15 createEnvironmentRpcQueryAtomFamily,
16 createEnvironmentRpcSubscriptionAtomFamily,
17} from "./runtime.ts";
18import type { EnvironmentRegistry } from "../connection/registry.ts";
19
20export interface ServerConfigProjection {
21 readonly config: ServerConfig;
22 readonly latestEvent: ServerConfigStreamEvent;
23}
24
25export 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
63export 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
71export 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
88export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5
6import { initialConfigOption } from "./session.ts";
7
8class TestConfigError extends Schema.TaggedErrorClass<TestConfigError>()("TestConfigError", {
9 message: Schema.String,
10}) {}
11
12describe("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 @@
1import type { EnvironmentId, ServerConfig } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5import * as SubscriptionRef from "effect/SubscriptionRef";
6import { AsyncResult, Atom } from "effect/unstable/reactivity";
7
8import { EnvironmentRegistry } from "../connection/registry.ts";
9import type { PreparedConnection } from "../connection/model.ts";
10import { EnvironmentSupervisor } from "../connection/supervisor.ts";
11import { followStreamInEnvironment } from "./runtime.ts";
12
13export 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
26export 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 @@
1import {
2 EnvironmentId,
3 ORCHESTRATION_WS_METHODS,
4 type OrchestrationShellSnapshot,
5 type OrchestrationShellStreamItem,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import * as Queue from "effect/Queue";
11import * as Stream from "effect/Stream";
12import * as SubscriptionRef from "effect/SubscriptionRef";
13
14import {
15 AVAILABLE_CONNECTION_STATE,
16 PrimaryConnectionTarget,
17 type PreparedConnection,
18} from "../connection/model.ts";
19import {
20 EnvironmentSupervisor,
21 type EnvironmentSupervisorService,
22} from "../connection/supervisor.ts";
23import { EnvironmentCacheStore } from "../platform/persistence.ts";
24import type { RpcSession } from "../rpc/session.ts";
25import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
26import { makeEnvironmentShellState } from "./shell.ts";
27
28const 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
35const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = {
36 snapshotSequence: 1,
37 projects: [],
38 threads: [],
39 updatedAt: "2026-06-06T00:00:00.000Z",
40};
41
42function 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
52describe("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 @@
1import type { ServerConfig } from "@t3tools/contracts";
2import { EnvironmentId } from "@t3tools/contracts";
3import { describe, expect, it } from "@effect/vitest";
4import * as Option from "effect/Option";
5import { Atom, AtomRegistry } from "effect/unstable/reactivity";
6
7import { PrimaryConnectionTarget } from "../connection/model.ts";
8import type { EnvironmentShellState } from "./shell.ts";
9import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts";
10
11const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
12const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2");
13
14function 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
26function 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
47function 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
90describe("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 @@
1import {
2 ORCHESTRATION_WS_METHODS,
3 type EnvironmentId,
4 type OrchestrationShellSnapshot,
5 type OrchestrationShellStreamItem,
6 type ServerConfig,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11import * as Queue from "effect/Queue";
12import * as Stream from "effect/Stream";
13import * as SubscriptionRef from "effect/SubscriptionRef";
14import { AsyncResult, Atom } from "effect/unstable/reactivity";
15
16import { EnvironmentRegistry } from "../connection/registry.ts";
17import { connectionProjectionPhase } from "../connection/model.ts";
18import { EnvironmentSupervisor } from "../connection/supervisor.ts";
19import { EnvironmentCacheStore } from "../platform/persistence.ts";
20import { subscribe } from "../rpc/client.ts";
21import { applyShellStreamEvent } from "./shellReducer.ts";
22import type { EnvironmentCatalogState } from "./connections.ts";
23import { followStreamInEnvironment } from "./runtime.ts";
24
25export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live";
26
27export interface EnvironmentShellState {
28 readonly snapshot: Option.Option<OrchestrationShellSnapshot>;
29 readonly status: EnvironmentShellStatus;
30 readonly error: Option.Option<string>;
31}
32
33const EMPTY_SHELL_STATE: EnvironmentShellState = {
34 snapshot: Option.none(),
35 status: "empty",
36 error: Option.none(),
37};
38
39function shellStatusForSnapshot(
40 snapshot: Option.Option<OrchestrationShellSnapshot>,
41): EnvironmentShellStatus {
42 return Option.isSome(snapshot) ? "cached" : "empty";
43}
44
45function 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
51export 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
169export function shellStateChanges(environmentId: EnvironmentId) {
170 return followStreamInEnvironment(
171 environmentId,
172 Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))),
173 );
174}
175
176export 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
185const 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
194const EMPTY_SERVER_CONFIGS: ReadonlyMap<EnvironmentId, ServerConfig> = new Map();
195
196function 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
210function 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
222export 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
269export 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
290export 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
311export * from "./models.ts";
312export * from "./shellCommands.ts";
313export * from "./shellReducer.ts";
314export * from "./snapshots.ts";
packages/client-runtime/src/state/shellCommands.ts created+16
...@@ -0,0 +1,16 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";
3
4import { createEnvironmentRpcCommand } from "./runtime.ts";
5import type { EnvironmentRegistry } from "../connection/registry.ts";
6
7export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts";
5
6import { applyShellStreamEvent } from "./shellReducer.ts";
7
8const baseSnapshot: OrchestrationShellSnapshot = {
9 snapshotSequence: 0,
10 projects: [],
11 threads: [],
12 updatedAt: "2026-04-01T00:00:00.000Z",
13};
14
15const 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
26const 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
46describe("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 @@
1import * as Arr from "effect/Array";
2import 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 */
12export 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 @@
1import type { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import { AsyncResult, Atom } from "effect/unstable/reactivity";
4
5import type { EnvironmentShellState } from "./shell.ts";
6
7export 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 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";
3
4import {
5 createAtomCommandScheduler,
6 createEnvironmentRpcCommand,
7 createEnvironmentRpcQueryAtomFamily,
8} from "./runtime.ts";
9import type { EnvironmentRegistry } from "../connection/registry.ts";
10import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts";
11
12export 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 @@
1import { type TerminalSummary, WS_METHODS } from "@t3tools/contracts";
2import * as Stream from "effect/Stream";
3import { Atom } from "effect/unstable/reactivity";
4
5import {
6 createAtomCommandScheduler,
7 createEnvironmentRpcCommand,
8 createEnvironmentRpcSubscriptionAtomFamily,
9 createEnvironmentSubscriptionAtomFamily,
10} from "./runtime.ts";
11import type { EnvironmentRegistry } from "../connection/registry.ts";
12import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts";
13import {
14 applyTerminalAttachStreamEvent,
15 applyTerminalMetadataStreamEvent,
16 EMPTY_TERMINAL_BUFFER_STATE,
17} from "./terminalSession.ts";
18
19export 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
95export * from "./terminalSession.ts";
packages/client-runtime/src/state/terminalSession.test.ts created+187
...@@ -0,0 +1,187 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts";
4
5import {
6 applyTerminalAttachStreamEvent,
7 applyTerminalMetadataStreamEvent,
8 combineTerminalSessionState,
9 EMPTY_TERMINAL_BUFFER_STATE,
10 selectRunningSubprocessTerminalIds,
11} from "./terminalSession.ts";
12
13const TARGET = {
14 environmentId: EnvironmentId.make("env-local"),
15 threadId: ThreadId.make("thread-1"),
16 terminalId: "term-1",
17} as const;
18
19const 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
33describe("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 @@
1import type {
2 EnvironmentId,
3 TerminalAttachStreamEvent,
4 TerminalMetadataStreamEvent,
5 TerminalSessionSnapshot,
6 TerminalSummary,
7 ThreadId,
8} from "@t3tools/contracts";
9
10export 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
20export 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
28export interface KnownTerminalSessionTarget {
29 readonly environmentId: EnvironmentId;
30 readonly threadId: ThreadId;
31 readonly terminalId: string;
32}
33
34export interface KnownTerminalSession {
35 readonly target: KnownTerminalSessionTarget;
36 readonly state: TerminalSessionState;
37}
38
39export 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
47export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({
48 buffer: "",
49 status: "closed",
50 error: null,
51 updatedAt: null,
52 version: 0,
53});
54
55export 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
65export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024;
66const textEncoder = new TextEncoder();
67const textDecoder = new TextDecoder();
68
69function 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
91export 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
104function 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
110export 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
125export 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
175export 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 @@
1import * as Crypto from "effect/Crypto";
2import { Atom } from "effect/unstable/reactivity";
3
4import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts";
5import {
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";
33import type { EnvironmentRegistry } from "../connection/registry.ts";
34
35export 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
51export 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 @@
1import type {
2 OrchestrationCheckpointSummary,
3 OrchestrationLatestTurn,
4 OrchestrationMessage,
5 OrchestrationProposedPlan,
6 OrchestrationSession,
7 OrchestrationThread,
8 OrchestrationThreadActivity,
9 ScopedThreadRef,
10} from "@t3tools/contracts";
11import * as Option from "effect/Option";
12import { AsyncResult, Atom } from "effect/unstable/reactivity";
13
14import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts";
15import { scopeThread } from "./models.ts";
16import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";
17import { parseThreadKey, threadKey } from "./entities.ts";
18
19const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]);
20const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]);
21const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]);
22const EMPTY_CHECKPOINTS: ReadonlyArray<OrchestrationCheckpointSummary> = Object.freeze([]);
23const 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 */
34export 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
64export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 CheckpointRef,
5 EventId,
6 MessageId,
7 ProjectId,
8 ProviderInstanceId,
9 ThreadId,
10 TurnId,
11} from "@t3tools/contracts";
12import type { OrchestrationThread } from "@t3tools/contracts";
13
14import { applyThreadDetailEvent } from "./threadReducer.ts";
15
16const baseEventFields = {
17 eventId: EventId.make("event-1"),
18 commandId: null,
19 causationEventId: null,
20 correlationId: null,
21 metadata: {},
22} as const;
23
24const 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
45describe("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 @@
1import { pipe } from "effect/Function";
2import * as Arr from "effect/Array";
3import * as O from "effect/Order";
4import type {
5 MessageId,
6 OrchestrationCheckpointSummary,
7 OrchestrationEvent,
8 OrchestrationLatestTurn,
9 OrchestrationMessage,
10 OrchestrationSession,
11 OrchestrationThread,
12 OrchestrationThreadActivity,
13 TurnId,
14} from "@t3tools/contracts";
15
16export type ThreadDetailReducerResult =
17 | { readonly kind: "updated"; readonly thread: OrchestrationThread }
18 | { readonly kind: "deleted" }
19 | { readonly kind: "unchanged" };
20
21const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>(
22 O.mapInput(O.String, (p) => p.createdAt),
23 O.mapInput(O.String, (p) => p.id),
24);
25
26const checkpointOrder = O.mapInput(
27 O.Number,
28 (cp: OrchestrationThread["checkpoints"][number]) =>
29 cp.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER,
30);
31
32const 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 */
47export 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 */
491function 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
509function 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
522function 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
532function 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 @@
1import type {
2 EnvironmentId,
3 OrchestrationShellSnapshot,
4 OrchestrationThreadShell,
5 ProjectId,
6 ScopedProjectRef,
7 ScopedThreadRef,
8 ThreadId,
9} from "@t3tools/contracts";
10import { Atom } from "effect/unstable/reactivity";
11
12import type { EnvironmentThreadShell } from "./models.ts";
13import { scopeThreadShell } from "./models.ts";
14import type { EnvironmentCatalogState } from "./connections.ts";
15import {
16 arrayElementsEqual,
17 parseProjectRefCollectionKey,
18 parseThreadKey,
19 projectRefCollectionKey,
20 threadKey,
21 threadRefsEqual,
22} from "./entities.ts";
23
24const EMPTY_THREADS: ReadonlyArray<OrchestrationThreadShell> = Object.freeze([]);
25const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
26const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationThreadShell> = new Map();
27const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap<
28 ProjectId,
29 ReadonlyArray<ScopedThreadRef>
30> = new Map();
31
32export 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 @@
1import {
2 EnvironmentId,
3 EventId,
4 ORCHESTRATION_WS_METHODS,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8 type OrchestrationThread,
9 type OrchestrationThreadStreamItem,
10} from "@t3tools/contracts";
11import { describe, expect, it } from "@effect/vitest";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14import * as Queue from "effect/Queue";
15import * as Ref from "effect/Ref";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18import * as TestClock from "effect/testing/TestClock";
19
20import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
21import {
22 AVAILABLE_CONNECTION_STATE,
23 PrimaryConnectionTarget,
24 type PreparedConnection,
25 type SupervisorConnectionState,
26} from "../connection/model.ts";
27import {
28 EnvironmentSupervisor,
29 type EnvironmentSupervisorService,
30} from "../connection/supervisor.ts";
31import { EnvironmentCacheStore } from "../platform/persistence.ts";
32import type { RpcSession } from "../rpc/session.ts";
33import {
34 EMPTY_ENVIRONMENT_THREAD_STATE,
35 makeEnvironmentThreadState,
36 type EnvironmentThreadState,
37} from "./threads.ts";
38
39const 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});
45const THREAD_ID = ThreadId.make("thread-1");
46const 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
70type TestThreadInput = OrchestrationThreadStreamItem | Error;
71
72function 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
82function 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
93const 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
173const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem => ({
174 kind: "snapshot",
175 snapshot: {
176 snapshotSequence: 1,
177 thread,
178 },
179});
180
181const 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
202const 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
222describe("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 @@
1import {
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";
10import * as Cause from "effect/Cause";
11import * as Effect from "effect/Effect";
12import * as Option from "effect/Option";
13import * as Queue from "effect/Queue";
14import * as Stream from "effect/Stream";
15import * as SubscriptionRef from "effect/SubscriptionRef";
16import { Atom } from "effect/unstable/reactivity";
17
18import { EnvironmentRegistry } from "../connection/registry.ts";
19import { connectionProjectionPhase } from "../connection/model.ts";
20import { EnvironmentSupervisor } from "../connection/supervisor.ts";
21import { EnvironmentCacheStore } from "../platform/persistence.ts";
22import { subscribe } from "../rpc/client.ts";
23import { applyThreadDetailEvent } from "./threadReducer.ts";
24import { followStreamInEnvironment } from "./runtime.ts";
25
26export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";
27
28export interface EnvironmentThreadState {
29 readonly data: Option.Option<OrchestrationThread>;
30 readonly status: EnvironmentThreadStatus;
31 readonly error: Option.Option<string>;
32}
33
34export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = {
35 data: Option.none(),
36 status: "empty",
37 error: Option.none(),
38};
39
40function statusWithoutLiveData(data: Option.Option<OrchestrationThread>): EnvironmentThreadStatus {
41 return Option.isSome(data) ? "cached" : "empty";
42}
43
44function 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
51export 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
222export function threadStateChanges(environmentId: EnvironmentIdType, threadId: ThreadIdType) {
223 return followStreamInEnvironment(
224 environmentId,
225 Stream.unwrap(makeEnvironmentThreadState(threadId).pipe(Effect.map(SubscriptionRef.changes))),
226 );
227}
228
229function threadAtomKey(environmentId: EnvironmentIdType, threadId: ThreadIdType): string {
230 return `${environmentId}\u0000${threadId}`;
231}
232
233function 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
247export 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
263export * from "./archivedThreads.ts";
264export * from "./checkpointDiff.ts";
265export * from "./composerPathSearch.ts";
266export * from "./threadCommands.ts";
267export * from "./threadDetail.ts";
268export * from "./threadReducer.ts";
269export * from "./threadShell.ts";
packages/client-runtime/src/state/vcs.ts created+85
...@@ -0,0 +1,85 @@
1import { type VcsStatusResult, WS_METHODS } from "@t3tools/contracts";
2import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
3import * as Stream from "effect/Stream";
4import { Atom } from "effect/unstable/reactivity";
5
6import {
7 createEnvironmentRpcCommand,
8 createEnvironmentRpcQueryAtomFamily,
9 createEnvironmentSubscriptionAtomFamily,
10} from "./runtime.ts";
11import type { EnvironmentRegistry } from "../connection/registry.ts";
12import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts";
13import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts";
14
15export 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
82export * from "./gitActions.ts";
83export * from "./vcsAction.ts";
84export * from "./vcsRef.ts";
85export * from "./vcsStatus.ts";
packages/client-runtime/src/state/vcsAction.test.ts created+342
...@@ -0,0 +1,342 @@
1import {
2 EnvironmentId,
3 type GitActionProgressEvent,
4 type GitRunStackedActionResult,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "@effect/vitest";
7import * as Cause from "effect/Cause";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Stream from "effect/Stream";
11import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
12
13import type { EnvironmentRegistry } from "../connection/registry.ts";
14import type { AtomCommandResult } from "./runtime.ts";
15import {
16 applyVcsActionProgressEvent,
17 beginVcsActionState,
18 consumeVcsActionProgress,
19 createVcsActionManager,
20 createVcsActionTransportId,
21 EMPTY_VCS_ACTION_STATE,
22 getVcsActionTargetKey,
23 normalizeVcsActionProgressEvent,
24} from "./vcsAction.ts";
25
26const actionId = "action-123";
27const action = "commit_push" as const;
28const cwd = "/repo";
29const environmentId = EnvironmentId.make("environment-1");
30const 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
55function progress<T extends GitActionProgressEvent>(event: T): T {
56 return event;
57}
58
59describe("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 @@
1import {
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";
10import * as Cause from "effect/Cause";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Schema from "effect/Schema";
14import * as Stream from "effect/Stream";
15import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity";
16
17import type { EnvironmentRegistry } from "../connection/registry.ts";
18import { runStream } from "../rpc/client.ts";
19import {
20 createRuntimeCommand,
21 runStreamInEnvironment,
22 type AtomCommand,
23 type AtomCommandResult,
24} from "./runtime.ts";
25import { vcsCommandScheduler } from "./vcsCommandScheduler.ts";
26
27export 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
38export 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
52export interface VcsActionTarget {
53 readonly environmentId: EnvironmentIdType | null;
54 readonly cwd: string | null;
55}
56
57export interface ResolvedVcsActionTarget {
58 readonly environmentId: EnvironmentIdType;
59 readonly cwd: string;
60}
61
62export interface BeginVcsActionInput {
63 readonly operation: VcsActionOperation;
64 readonly label: string;
65 readonly actionId?: string;
66}
67
68export 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
77export class VcsActionUnavailableError extends Schema.TaggedErrorClass<VcsActionUnavailableError>()(
78 "VcsActionUnavailableError",
79 {
80 message: Schema.String,
81 },
82) {}
83
84export class VcsActionExecutionError extends Schema.TaggedErrorClass<VcsActionExecutionError>()(
85 "VcsActionExecutionError",
86 {
87 message: Schema.String,
88 },
89) {}
90
91export 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
105const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe());
106let nextLocalActionId = 0;
107
108export 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
115export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe(
116 Atom.keepAlive,
117 Atom.withLabel("vcs-action:null"),
118);
119
120export 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
127function 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
135export function getVcsActionStateAtom(target: VcsActionTarget) {
136 const key = getVcsActionTargetKey(target);
137 return key === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key);
138}
139
140function createLocalActionId(): string {
141 nextLocalActionId += 1;
142 return `local-vcs-action:${nextLocalActionId}`;
143}
144
145export 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
161export 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
174export 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
182export 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
197export 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
245export 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
339export 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 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3import { createAtomCommandScheduler, type AtomCommandConcurrency } from "./runtime.ts";
4
5export const vcsCommandScheduler = createAtomCommandScheduler();
6
7export 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 @@
1import type { EnvironmentId, VcsRef as ContractVcsRef } from "@t3tools/contracts";
2
3export interface VcsRefTarget {
4 readonly environmentId: EnvironmentId | null;
5 readonly cwd: string | null;
6 readonly query?: string | null;
7}
8
9export type VcsRef = ContractVcsRef;
packages/client-runtime/src/state/vcsStatus.ts created+6
...@@ -0,0 +1,6 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3export 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 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2import { afterEach, describe, expect, it } from "vite-plus/test";
3
4import {
5 EnvironmentId,
6 TerminalAttachStreamEvent,
7 TerminalMetadataStreamEvent,
8 TerminalSessionSnapshot,
9 ThreadId,
10} from "@t3tools/contracts";
11
12import {
13 createTerminalSessionManager,
14 getKnownTerminalSessionListFilter,
15 knownTerminalSessionsAtom,
16 runningTerminalIdsAtom,
17 terminalSessionStateAtom,
18 type KnownTerminalSessionTarget,
19} from "./terminalSessionState.ts";
20
21let atomRegistry = AtomRegistry.make();
22
23function resetAtomRegistry() {
24 atomRegistry.dispose();
25 atomRegistry = AtomRegistry.make();
26}
27
28const TARGET = {
29 environmentId: EnvironmentId.make("env-local"),
30 threadId: ThreadId.make("thread-1"),
31 terminalId: "term-1",
32} as const;
33
34const 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
48type TerminalSessionManager = ReturnType<typeof createTerminalSessionManager>;
49
50function 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
72function 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
90describe("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 @@
1import type {
2 TerminalAttachStreamEvent,
3 TerminalMetadataStreamEvent,
4 TerminalSessionSnapshot,
5 TerminalSummary,
6 EnvironmentId,
7} from "@t3tools/contracts";
8import { ThreadId, type TerminalAttachInput } from "@t3tools/contracts";
9import * as Arr from "effect/Array";
10import { pipe } from "effect/Function";
11import * as Order from "effect/Order";
12import * as Result from "effect/Result";
13import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
14
15export 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
25export 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
33export interface TerminalSessionTarget {
34 readonly environmentId: EnvironmentId | null;
35 readonly threadId: ThreadId | null;
36 readonly terminalId: string | null;
37}
38
39export interface KnownTerminalSessionTarget {
40 readonly environmentId: EnvironmentId;
41 readonly threadId: ThreadId;
42 readonly terminalId: string;
43}
44
45export interface KnownTerminalSession {
46 readonly target: KnownTerminalSessionTarget;
47 readonly state: TerminalSessionState;
48}
49
50export interface KnownTerminalMetadata {
51 readonly target: KnownTerminalSessionTarget;
52 readonly summary: TerminalSummary;
53}
54
55export interface TerminalSessionListFilter {
56 readonly environmentId: EnvironmentId | null;
57 readonly threadId?: ThreadId | null;
58 readonly terminalId?: string | null;
59}
60
61export interface KnownTerminalSessionListFilter {
62 readonly environmentId: EnvironmentId;
63 readonly threadId: ThreadId | null;
64 readonly terminalId: string | null;
65}
66
67export interface TerminalSessionManagerConfig {
68 readonly getRegistry: () => AtomRegistry.AtomRegistry;
69 readonly maxBufferBytes?: number;
70}
71
72export interface TerminalMetadataClient {
73 readonly terminal: {
74 readonly onMetadata: (
75 listener: (event: TerminalMetadataStreamEvent) => void,
76 options?: { readonly onResubscribe?: () => void },
77 ) => () => void;
78 };
79}
80
81export 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
91export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({
92 buffer: "",
93 status: "closed",
94 error: null,
95 updatedAt: null,
96 version: 0,
97});
98
99export 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
109const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<Array<KnownTerminalSession>>([]);
110const EMPTY_TERMINAL_ID_LIST = Object.freeze<Array<string>>([]);
111const DEFAULT_MAX_BUFFER_BYTES = 512 * 1024;
112const knownTerminalMetadataEnvironmentIds = new Set<EnvironmentId>();
113const knownTerminalBufferTargets = new Map<string, KnownTerminalSessionTarget>();
114const textEncoder = new TextEncoder();
115const textDecoder = new TextDecoder();
116const terminalIdOrder = Order.make<string>(
117 (left, right) => left.localeCompare(right, undefined, { numeric: true }) as -1 | 0 | 1,
118);
119const knownTerminalSessionOrder = Order.mapInput(
120 terminalIdOrder,
121 (session: KnownTerminalSession) => session.target.terminalId,
122);
123
124export 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
132export 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
141export const EMPTY_TERMINAL_BUFFER_ATOM = Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe(
142 Atom.keepAlive,
143 Atom.withLabel("terminal-session:buffer:null"),
144);
145
146export const EMPTY_TERMINAL_SESSION_ATOM = Atom.make(EMPTY_TERMINAL_SESSION_STATE).pipe(
147 Atom.keepAlive,
148 Atom.withLabel("terminal-session:state:null"),
149);
150
151export 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
156export 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
161export 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
175export 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
189function 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
200function keyFromKnownTarget(target: KnownTerminalSessionTarget): string {
201 return `${target.environmentId}:${target.threadId}:${target.terminalId}`;
202}
203
204function 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
226function 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
239function 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
245function 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
260function 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
286export 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
296export 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
310export 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
330export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 CheckpointRef,
5 EventId,
6 MessageId,
7 ProjectId,
8 ProviderInstanceId,
9 ThreadId,
10 TurnId,
11} from "@t3tools/contracts";
12import type { OrchestrationThread } from "@t3tools/contracts";
13
14import { applyThreadDetailEvent } from "./threadDetailReducer.ts";
15
16const baseEventFields = {
17 eventId: EventId.make("event-1"),
18 commandId: null,
19 causationEventId: null,
20 correlationId: null,
21 metadata: {},
22} as const;
23
24const 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
45describe("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 @@
1import { pipe } from "effect/Function";
2import * as Arr from "effect/Array";
3import * as O from "effect/Order";
4import 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 */
20export interface ThreadDetailRetentionLimits {
21 readonly maxMessages: number;
22 readonly maxProposedPlans: number;
23 readonly maxCheckpoints: number;
24 readonly maxActivities: number;
25}
26
27export const DEFAULT_THREAD_DETAIL_LIMITS: ThreadDetailRetentionLimits = {
28 maxMessages: 512,
29 maxProposedPlans: 64,
30 maxCheckpoints: 256,
31 maxActivities: 128,
32};
33
34export type ThreadDetailReducerResult =
35 | { readonly kind: "updated"; readonly thread: OrchestrationThread }
36 | { readonly kind: "deleted" }
37 | { readonly kind: "unchanged" };
38
39const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>(
40 O.mapInput(O.String, (p) => p.createdAt),
41 O.mapInput(O.String, (p) => p.id),
42);
43
44const checkpointOrder = O.mapInput(
45 O.Number,
46 (cp: OrchestrationThread["checkpoints"][number]) =>
47 cp.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER,
48);
49
50const 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 */
65export 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 */
520function 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
538function 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
551function 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
561function 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 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4import {
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
16import { createThreadDetailManager, type ThreadDetailClient } from "./threadDetailState.ts";
17
18let atomRegistry = AtomRegistry.make();
19
20function resetAtomRegistry() {
21 atomRegistry.dispose();
22 atomRegistry = AtomRegistry.make();
23}
24
25function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
26 listeners.add(listener);
27 return () => {
28 listeners.delete(listener);
29 };
30}
31
32const baseEventFields = {
33 eventId: EventId.make("event-1"),
34 commandId: null,
35 causationEventId: null,
36 correlationId: null,
37 metadata: {},
38} as const;
39
40const 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
61const TARGET = {
62 environmentId: EnvironmentId.make("env-local"),
63 threadId: ThreadId.make("thread-1"),
64} as const;
65
66function 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
89describe("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 @@
1import { pipe } from "effect/Function";
2import * as Order from "effect/Order";
3import * as Arr from "effect/Array";
4import * as DateTime from "effect/DateTime";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import type {
9 OrchestrationThread,
10 OrchestrationThreadStreamItem,
11 EnvironmentId,
12 ThreadId as ThreadIdType,
13} from "@t3tools/contracts";
14import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
15
16import {
17 DEFAULT_THREAD_DETAIL_LIMITS,
18 applyThreadDetailEvent,
19 type ThreadDetailRetentionLimits,
20} from "./threadDetailReducer.ts";
21import type { WsRpcClient } from "./wsRpcClient.ts";
22
23export interface ThreadDetailState {
24 readonly data: OrchestrationThread | null;
25 readonly error: string | null;
26 readonly isPending: boolean;
27 readonly isDeleted: boolean;
28}
29
30export interface ThreadDetailTarget {
31 readonly environmentId: EnvironmentId | null;
32 readonly threadId: ThreadIdType | null;
33}
34
35export type ThreadDetailClient = Pick<WsRpcClient["orchestration"], "subscribeThread">;
36
37export 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
46interface 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
58const NOOP: () => void = () => undefined;
59const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe());
60
61function clearEntryEviction(entry: ThreadDetailEntry): void {
62 if (entry.evictionFiber !== null) {
63 Effect.runFork(Fiber.interrupt(entry.evictionFiber));
64 entry.evictionFiber = null;
65 }
66}
67
68export const EMPTY_THREAD_DETAIL_STATE = Object.freeze<ThreadDetailState>({
69 data: null,
70 error: null,
71 isPending: false,
72 isDeleted: false,
73});
74
75const INITIAL_THREAD_DETAIL_STATE = Object.freeze<ThreadDetailState>({
76 data: null,
77 error: null,
78 isPending: true,
79 isDeleted: false,
80});
81
82const knownThreadDetailKeys = new Set<string>();
83
84export 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
92export const EMPTY_THREAD_DETAIL_ATOM = Atom.make(EMPTY_THREAD_DETAIL_STATE).pipe(
93 Atom.keepAlive,
94 Atom.withLabel("thread-detail:null"),
95);
96
97export 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
105export 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
114export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transportError.ts";
4
5describe("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
45describe("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 @@
1const 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 */
14export 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 */
32export 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 @@
1import {
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";
11import { AtomRegistry } from "effect/unstable/reactivity";
12import { afterEach, describe, expect, it, vi } from "vite-plus/test";
13
14import {
15 type VcsActionClient,
16 createVcsActionManager,
17 EMPTY_VCS_ACTION_STATE,
18} from "./vcsActionState.ts";
19
20let atomRegistry = AtomRegistry.make();
21
22function resetAtomRegistry() {
23 atomRegistry.dispose();
24 atomRegistry = AtomRegistry.make();
25}
26
27function 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
37const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const;
38
39const 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
52function 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
63function 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
73function 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
85function 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
97function 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
122function 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
160describe("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 @@
1import 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";
17import * as DateTime from "effect/DateTime";
18import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
19
20import { buildGitActionProgressStages } from "./gitActions.ts";
21import type { WsRpcClient } from "./wsRpcClient.ts";
22
23export type VcsActionOperation =
24 | "refresh_status"
25 | "run_change_request"
26 | "pull"
27 | "switch_ref"
28 | "create_ref"
29 | "create_worktree"
30 | "init";
31
32export 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
46export interface VcsActionTarget {
47 readonly environmentId: EnvironmentId | null;
48 readonly cwd: string | null;
49}
50
51export type VcsActionClient = Pick<
52 WsRpcClient["vcs"],
53 "refreshStatus" | "pull" | "switchRef" | "createRef" | "createWorktree" | "init"
54> & {
55 readonly runChangeRequest: WsRpcClient["git"]["runStackedAction"];
56};
57
58export 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
72const knownVcsActionKeys = new Set<string>();
73let nextGeneratedActionId = 0;
74const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe());
75
76export 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
84export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe(
85 Atom.keepAlive,
86 Atom.withLabel("vcs-action:null"),
87);
88
89export 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
96export 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
187export 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
194export 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 @@
1import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 createVcsRefManager,
7 EMPTY_VCS_REF_STATE,
8 vcsRefStateAtom,
9 type VcsRefClient,
10} from "./vcsRefState.ts";
11
12let atomRegistry = AtomRegistry.make();
13
14function resetAtomRegistry() {
15 atomRegistry.dispose();
16 atomRegistry = AtomRegistry.make();
17}
18
19const noop = () => undefined;
20
21const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const;
22
23const 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
34const 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
42function 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
66function 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
76describe("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 @@
1import type {
2 EnvironmentId,
3 VcsListRefsInput,
4 VcsListRefsResult,
5 VcsRef as ContractVcsRef,
6} from "@t3tools/contracts";
7import * as Clock from "effect/Clock";
8import * as Effect from "effect/Effect";
9import { Atom, type AtomRegistry, type AsyncResult } from "effect/unstable/reactivity";
10
11import type { WsRpcClient } from "./wsRpcClient.ts";
12
13export interface VcsRefTarget {
14 readonly environmentId: EnvironmentId | null;
15 readonly cwd: string | null;
16 readonly query?: string | null;
17}
18
19export interface VcsRefScope {
20 readonly environmentId: EnvironmentId | null;
21 readonly cwd: string | null;
22}
23
24export interface VcsRefState {
25 readonly data: VcsListRefsResult | null;
26 readonly isPending: boolean;
27 readonly error: string | null;
28}
29
30export type VcsRef = ContractVcsRef;
31export type VcsRefClient = Pick<WsRpcClient["vcs"], "listRefs">;
32
33export const EMPTY_VCS_REF_STATE = Object.freeze<VcsRefState>({
34 data: null,
35 isPending: false,
36 error: null,
37});
38
39const INITIAL_VCS_REF_STATE = Object.freeze<VcsRefState>({
40 data: null,
41 isPending: true,
42 error: null,
43});
44
45const knownVcsRefKeys = new Set<string>();
46
47export 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
52export const EMPTY_VCS_REF_ATOM = Atom.make(EMPTY_VCS_REF_STATE).pipe(
53 Atom.keepAlive,
54 Atom.withLabel("vcs-refs:null"),
55);
56
57function normalizeQuery(query: string | null | undefined): string {
58 return query?.trim() ?? "";
59}
60
61export 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
69function toErrorMessage(error: unknown): string {
70 return error instanceof Error ? error.message : "Failed to load refs.";
71}
72
73function 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
87export 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
96interface WatchedEntry {
97 refCount: number;
98 teardown: () => void;
99}
100
101const NOOP: () => void = () => undefined;
102
103export 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 @@
1import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts";
2import { AtomRegistry } from "effect/unstable/reactivity";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 type VcsStatusClient,
7 createVcsStatusManager,
8 getVcsStatusDataForTarget,
9} from "./vcsStatusState.ts";
10
11/* ─── Test helpers ──────────────────────────────────────────────────── */
12
13let atomRegistry = AtomRegistry.make();
14
15function resetAtomRegistry() {
16 atomRegistry.dispose();
17 atomRegistry = AtomRegistry.make();
18}
19
20function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
21 listeners.add(listener);
22 return () => {
23 listeners.delete(listener);
24 };
25}
26
27const 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
40function 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
64const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const;
65const FRESH_TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/fresh" } as const;
66const OTHER_ENV_TARGET = { environmentId: EnvironmentId.make("env-remote"), cwd: "/repo" } as const;
67const TARGET_KEY = "env-local:/repo";
68const PENDING = {
69 targetKey: TARGET_KEY,
70 data: null,
71 error: null,
72 cause: null,
73 isPending: true,
74};
75const EMPTY = {
76 targetKey: null,
77 data: null,
78 error: null,
79 cause: null,
80 isPending: false,
81};
82
83/* ─── Tests ─────────────────────────────────────────────────────────── */
84
85describe("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 @@
1import type { EnvironmentId, GitManagerServiceError, VcsStatusResult } from "@t3tools/contracts";
2import type * as Cause from "effect/Cause";
3import * as DateTime from "effect/DateTime";
4import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
5import type { WsRpcClient } from "./wsRpcClient.ts";
6
7/* ─── Types ─────────────────────────────────────────────────────────── */
8
9export 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
17export interface VcsStatusTarget {
18 readonly environmentId: EnvironmentId | null;
19 readonly cwd: string | null;
20}
21
22export type VcsStatusClient = Pick<WsRpcClient["vcs"], "onStatus" | "refreshStatus">;
23
24interface WatchedEntry {
25 refCount: number;
26 teardown: () => void;
27}
28
29/* ─── Constants ─────────────────────────────────────────────────────── */
30
31const NOOP: () => void = () => undefined;
32
33export 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
41function 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
53const knownVcsStatusKeys = new Set<string>();
54
55export 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
63export 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
70export 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
77export 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
87export 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
108const VCS_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
109const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe());
110
111export 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 @@
1import type {
2 VcsStatusLocalResult,
3 VcsStatusRemoteResult,
4 VcsStatusStreamEvent,
5} from "@t3tools/contracts";
6import { ORCHESTRATION_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts";
7import { describe, expect, it, vi } from "vite-plus/test";
8
9vi.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
19import { createWsRpcClient } from "./wsRpcClient.ts";
20import type { WsTransport } from "./wsTransport.ts";
21
22const 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
31const baseRemoteStatus: VcsStatusRemoteResult = {
32 hasUpstream: true,
33 aheadCount: 0,
34 behindCount: 0,
35 pr: null,
36};
37
38describe("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 @@
1import {
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";
14import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
15import type * as Effect from "effect/Effect";
16import type * as Stream from "effect/Stream";
17
18import { type WsRpcProtocolClient } from "./wsRpcProtocol.ts";
19import { WsTransport } from "./wsTransport.ts";
20
21type RpcTag = keyof WsRpcProtocolClient & string;
22type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag];
23type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0];
24
25interface StreamSubscriptionOptions {
26 readonly onResubscribe?: () => void;
27}
28
29function subscriptionOptions(
30 options: StreamSubscriptionOptions | undefined,
31 tag: string,
32): StreamSubscriptionOptions & { readonly tag: string } {
33 return {
34 ...options,
35 tag,
36 };
37}
38
39type 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
44type RpcUnaryNoArgMethod<TTag extends RpcTag> =
45 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
46 ? () => Promise<TSuccess>
47 : never;
48
49type 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
54type 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
63interface GitRunStackedActionOptions {
64 readonly onProgress?: (event: GitActionProgressEvent) => void;
65}
66
67export 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
194export interface CreateWsRpcClientOptions {
195 /** Runs immediately before `transport.reconnect()` (e.g. reset reconnect UI/backoff state). */
196 readonly beforeReconnect?: () => void;
197}
198
199export 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 @@
1import { WsRpcGroup } from "@t3tools/contracts";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schedule from "effect/Schedule";
6import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
7import * as Socket from "effect/unstable/socket/Socket";
8
9import {
10 DEFAULT_RECONNECT_BACKOFF,
11 getReconnectDelayMs,
12 type ReconnectBackoffConfig,
13} from "./reconnectBackoff.ts";
14
15export 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
48export interface WsRpcProtocolRequestTelemetry {
49 readonly onRequestSent?: (requestId: string, tag: string) => void;
50 readonly onRequestAcknowledged?: (requestId: string) => void;
51 readonly onClearTrackedRequests?: () => void;
52}
53
54export 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
66export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
67type RpcClientFactory = typeof makeWsRpcProtocolClient;
68export type WsRpcProtocolClient =
69 RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never;
70export type WsRpcProtocolSocketUrlProvider = string | (() => Promise<string>);
71
72function 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
80function 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
90type 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
107function 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
123function 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
195export 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 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import { WsTransport } from "./wsTransport.ts";
8
9type WsEventType = "open" | "message" | "close" | "error";
10type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
11type WsListener = (event?: WsEvent) => void;
12
13const sockets: MockWebSocket[] = [];
14
15class 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
72const originalWebSocket = globalThis.WebSocket;
73const originalFetch = globalThis.fetch;
74const transports: WsTransport[] = [];
75
76function 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
84async 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
99function createTransport(...args: ConstructorParameters<typeof WsTransport>): WsTransport {
100 const transport = new WsTransport(...args);
101 transports.push(transport);
102 return transport;
103}
104
105beforeEach(() => {
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
130afterEach(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
138describe("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 @@
1import * as Cause from "effect/Cause";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Layer from "effect/Layer";
6import * as ManagedRuntime from "effect/ManagedRuntime";
7import * as Scope from "effect/Scope";
8import * as Stream from "effect/Stream";
9import { RpcClient } from "effect/unstable/rpc";
10
11import { isTransportConnectionErrorMessage } from "./transportError.ts";
12import {
13 createWsRpcProtocolLayer,
14 makeWsRpcProtocolClient,
15 type WsProtocolLifecycleHandlers,
16 type WsRpcProtocolClient,
17 type WsRpcProtocolSocketUrlProvider,
18} from "./wsRpcProtocol.ts";
19
20export 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
41interface SubscribeOptions {
42 readonly retryDelay?: Duration.Input;
43 readonly onResubscribe?: () => void;
44 readonly tag?: string;
45}
46
47const DEFAULT_SUBSCRIPTION_RETRY_DELAY = Duration.millis(250);
48const NOOP: () => void = () => undefined;
49
50interface TransportSession {
51 readonly clientPromise: Promise<WsRpcProtocolClient>;
52 readonly clientScope: Scope.Closeable;
53 readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;
54}
55
56function 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
64export 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
375function 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});
715export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type;716export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type;
716717
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 );
8383
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};
3030
31const 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
65export interface NetServiceShape {31export 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 });
13399
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 = () => {
169182
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};
183196
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 @@
1import * as Cause from "effect/Cause";
1import * as Context from "effect/Context";2import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
3import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";6import * as Option from "effect/Option";
5import * as Tracer from "effect/Tracer";7import * 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 );
4143
44function 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
71function 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
90function 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
42export function makeRelayClientTracingLayer(108export 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));
66132
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.12291 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.14297 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.4518 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.2521 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.11606 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.0609 specifier: ^4.0.0
613 version: 4.3.0610 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)))
623617
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'
44084402
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: true4669 optional: true
46814670
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==}
53745334
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==}
61116067
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'}
61336086
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: '*'
62886237
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==}
65886543
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==}
79427892
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==}
79497895
...@@ -8197,11 +8143,6 @@ packages:...@@ -8197,11 +8143,6 @@ packages:
8197 engines: {node: '>=18'}8143 engines: {node: '>=18'}
8198 hasBin: true8144 hasBin: true
81998145
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'}
89188859
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==}
89248862
...@@ -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'}
90028940
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==}
90088943
...@@ -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}
92029137
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==}
92099140
...@@ -9569,61 +9500,6 @@ packages:...@@ -9569,61 +9500,6 @@ packages:
9569 vite:9500 vite:
9570 optional: true9501 optional: true
95719502
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==}
96299505
...@@ -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: true9644 hasBin: true
97699645
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.613533 react: 19.2.6
13663 react-dom: 19.2.6(react@19.2.6)13534 react-dom: 19.2.6(react@19.2.6)
1366413535
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.013538 '@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.013842 babel-plugin-react-compiler: 1.0.0
1397713843
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.013846 '@oxc-project/runtime': 0.133.0
...@@ -14806,8 +14630,6 @@ snapshots:...@@ -14806,8 +14630,6 @@ snapshots:
1480614630
14807 ccount@2.0.1: {}14631 ccount@2.0.1: {}
1480814632
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.114635 ansi-styles: 3.2.1
...@@ -15480,10 +15302,6 @@ snapshots:...@@ -15480,10 +15302,6 @@ snapshots:
1548015302
15481 estree-walker@2.0.2: {}15303 estree-walker@2.0.2: {}
1548215304
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: {}
1548815306
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.015315 eventsource-parser: 3.1.0
1549815316
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)
1567115487
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:
1609215913
16093 fs.realpath@1.0.0: {}15914 fs.realpath@1.0.0: {}
1609415915
16095 fsevents@2.3.2:
16096 optional: true
16097
16098 fsevents@2.3.3:15916 fsevents@2.3.3:
16099 optional: true15917 optional: true
1610015918
...@@ -17742,8 +17560,6 @@ snapshots:...@@ -17742,8 +17560,6 @@ snapshots:
1774217560
17743 obug@2.1.1: {}17561 obug@2.1.1: {}
1774417562
17745 obug@2.1.2: {}
17746
17747 ofetch@1.5.1:17563 ofetch@1.5.1:
17748 dependencies:17564 dependencies:
17749 destr: 2.0.517565 destr: 2.0.5
...@@ -18023,12 +17839,6 @@ snapshots:...@@ -18023,12 +17839,6 @@ snapshots:
1802317839
18024 playwright-core@1.60.0: {}17840 playwright-core@1.60.0: {}
1802517841
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.1317844 '@xmldom/xmldom': 0.8.13
...@@ -18966,8 +18776,6 @@ snapshots:...@@ -18966,8 +18776,6 @@ snapshots:
18966 side-channel-map: 1.0.118776 side-channel-map: 1.0.1
18967 side-channel-weakmap: 1.0.218777 side-channel-weakmap: 1.0.2
1896818778
18969 siginfo@2.0.0: {}
18970
18971 signal-exit@3.0.7: {}18779 signal-exit@3.0.7: {}
1897218780
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.018848 escape-string-regexp: 2.0.0
1904118849
19042 stackback@0.0.2: {}
19043
19044 stackframe@1.3.4: {}18850 stackframe@1.3.4: {}
1904518851
19046 stacktrace-parser@0.1.11:18852 stacktrace-parser@0.1.11:
...@@ -19228,8 +19034,6 @@ snapshots:...@@ -19228,8 +19034,6 @@ snapshots:
1922819034
19229 tinypool@2.1.0: {}19035 tinypool@2.1.0: {}
1923019036
19231 tinyrainbow@3.1.0: {}
19232
19233 tldts-core@7.4.2: {}19037 tldts-core@7.4.2: {}
1923419038
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)'
1957319377
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: {}
1961119379
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.019515 isexe: 4.0.0
1974819516
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.119519 string-width: 8.2.1
vite.config.ts+16-1
...@@ -1,9 +1,12 @@...@@ -1,9 +1,12 @@
1import "vite-plus/test/config";1import "vite-plus/test/config";
2import { defineConfig } from "vite-plus";2import { defineConfig } from "vite-plus";
3import { fileURLToPath } from "node:url";
34
4export default defineConfig({5export 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",