Skip to content

Prepare datamodel for multi-environment#1765

Merged
juliusmarminge merged 58 commits intomainfrom
t3code/remote-host-model
Apr 9, 2026
Merged

Prepare datamodel for multi-environment#1765
juliusmarminge merged 58 commits intomainfrom
t3code/remote-host-model

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 5, 2026

prep work for multi-environments

Summary

  • Adds a persistent server environment descriptor with stable environment IDs, platform info, server version, and capability flags.
  • Resolves repository identity from Git remotes and threads that metadata into project snapshots, websocket events, and replayed orchestration data.
  • Updates desktop bootstrap plumbing so the UI can read local environment metadata from the host runtime.
  • Expands server and shared contract coverage with new tests for environment persistence, repository identity resolution, and event enrichment.

Testing

  • Not run (PR content drafted from the diff only).
  • bun fmt - Not run.
  • bun lint - Not run.
  • bun typecheck - Not run.
  • bun run test - Not run.

Note

High Risk
High risk because it changes server->client contract payloads (server config/lifecycle + orchestration events), introduces new persisted identifiers, and touches core orchestration snapshot/event plumbing plus client routing/state keys.

Overview
Prepares the app for multi-environment support by introducing a persistent server ExecutionEnvironmentDescriptor (stable environmentId, platform, version, capability flags) and threading it into ServerRuntimeStartup lifecycle events and subscribeServerConfig payloads.

Adds a server-side RepositoryIdentityResolver (git-remote based, cached) and uses it to enrich project snapshot data and orchestration WS replay/live events with repositoryIdentity, including concurrent resolution in ProjectionSnapshotQuery.

Updates desktop IPC/preload to expose a getLocalEnvironmentBootstrap payload, expands tests to wire the new layers, and adjusts timeouts where needed; the web app starts adopting environment-scoped APIs/keys (e.g., environmentApi, scoped refs/keys) and updates related browser/logic tests accordingly.

Reviewed by Cursor Bugbot for commit aa92532. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Scope all project, thread, and API state to discrete environments to support multi-environment

  • Introduces a new @t3tools/client-runtime package with ScopedProjectRef, ScopedThreadRef, KnownEnvironment, and scoped key utilities for identifying resources across environments.
  • Restructures AppState in store.ts so projects, threads, and related state live under environmentStateById instead of globally; selectors and reducers updated throughout.
  • Splits NativeApi into LocalApi (desktop shell/dialogs) and EnvironmentApi (per-environment backend operations); all components and hooks updated to use the appropriate API.
  • Adds a RepositoryIdentityResolver server service that resolves a project's canonical git remote identity from its workspace root, enriching project.created and project.meta-updated events and snapshot data.
  • Adds a ServerEnvironment service that persists a stable EnvironmentId per server installation and exposes an ExecutionEnvironmentDescriptor (platform, version, capabilities) included in WS handshake and lifecycle events.
  • Introduces draft-session routes (/draft/$draftId) and environment-scoped thread routes (/$environmentId/$threadId), replacing the previous /$threadId route; draft promotion to server threads is now a two-step lifecycle.
  • Terminal, git status, composer draft, and thread-selection stores are rekeyed from raw ThreadId/ProjectId to environment-scoped string keys; persisted state versions are bumped with migration paths.
  • Risk: breaking change to all route shapes, stored state schemas (v2 migrations for terminal and composer draft stores), and store/API call signatures across the frontend.

Macroscope summarized aa92532.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 5, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1798368d-387d-41b2-b414-bfcc7ce1149c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/remote-host-model

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Apr 5, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Redundant no-op identity map in effect pipeline
    • Removed the no-op Effect.map((option) => option) line that was a leftover from refactoring.
  • ✅ Fixed: Replay events enriched twice in subscription handler
    • Moved enrichProjectEvent to only wrap the live streamDomainEvents stream so already-enriched replay events skip the redundant enrichment pass.
  • ✅ Fixed: Selector creates unstable array references every call
    • Added a per-projectScopedId reference-equality cache that returns the previous result array when the underlying scopedIds and sidebarMap references haven't changed.

Create PR

Or push these changes by commenting:

@cursor push 481ff4254f
Preview (481ff4254f)
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -748,7 +748,6 @@
             "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow",
           ),
         ),
-        Effect.map((option) => option),
         Effect.flatMap((option) =>
           Option.isNone(option)
             ? Effect.succeed(Option.none<OrchestrationProject>())

diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -503,7 +503,10 @@
               Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),
             );
             const replayStream = Stream.fromIterable(replayEvents);
-            const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents);
+            const liveStream = orchestrationEngine.streamDomainEvents.pipe(
+              Stream.mapEffect(enrichProjectEvent),
+            );
+            const source = Stream.merge(replayStream, liveStream);
             type SequenceState = {
               readonly nextSequence: number;
               readonly pendingBySequence: Map<number, OrchestrationEvent>;
@@ -515,43 +518,33 @@
 
             return source.pipe(
               Stream.mapEffect((event) =>
-                enrichProjectEvent(event).pipe(
-                  Effect.flatMap((enrichedEvent) =>
-                    Ref.modify(
-                      state,
-                      ({
-                        nextSequence,
-                        pendingBySequence,
-                      }): [Array<OrchestrationEvent>, SequenceState] => {
-                        if (
-                          enrichedEvent.sequence < nextSequence ||
-                          pendingBySequence.has(enrichedEvent.sequence)
-                        ) {
-                          return [[], { nextSequence, pendingBySequence }];
-                        }
+                Ref.modify(
+                  state,
+                  ({
+                    nextSequence,
+                    pendingBySequence,
+                  }): [Array<OrchestrationEvent>, SequenceState] => {
+                    if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) {
+                      return [[], { nextSequence, pendingBySequence }];
+                    }
 
-                        const updatedPending = new Map(pendingBySequence);
-                        updatedPending.set(enrichedEvent.sequence, enrichedEvent);
+                    const updatedPending = new Map(pendingBySequence);
+                    updatedPending.set(event.sequence, event);
 
-                        const emit: Array<OrchestrationEvent> = [];
-                        let expected = nextSequence;
-                        for (;;) {
-                          const expectedEvent = updatedPending.get(expected);
-                          if (!expectedEvent) {
-                            break;
-                          }
-                          emit.push(expectedEvent);
-                          updatedPending.delete(expected);
-                          expected += 1;
-                        }
+                    const emit: Array<OrchestrationEvent> = [];
+                    let expected = nextSequence;
+                    for (;;) {
+                      const expectedEvent = updatedPending.get(expected);
+                      if (!expectedEvent) {
+                        break;
+                      }
+                      emit.push(expectedEvent);
+                      updatedPending.delete(expected);
+                      expected += 1;
+                    }
 
-                        return [
-                          emit,
-                          { nextSequence: expected, pendingBySequence: updatedPending },
-                        ];
-                      },
-                    ),
-                  ),
+                    return [emit, { nextSequence: expected, pendingBySequence: updatedPending }];
+                  },
                 ),
               ),
               Stream.flatMap((events) => Stream.fromIterable(events)),

diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -1305,22 +1305,36 @@
         ]
       : undefined;
 
+const _threadIdsByProjectCache = new Map<
+  string,
+  { scopedIds: string[]; sidebarMap: Record<string, SidebarThreadSummary>; result: ThreadId[] }
+>();
+
 export const selectThreadIdsByProjectId =
   (projectId: ProjectId | null | undefined) =>
-  (state: AppState): ThreadId[] =>
-    projectId
-      ? (
-          state.threadScopedIdsByProjectScopedId[
-            getProjectScopedId({
-              environmentId: state.activeEnvironmentId,
-              id: projectId,
-            })
-          ] ?? EMPTY_SCOPED_IDS
-        )
-          .map((scopedId) => state.sidebarThreadsByScopedId[scopedId]?.id ?? null)
-          .filter((threadId): threadId is ThreadId => threadId !== null)
-      : EMPTY_THREAD_IDS;
+  (state: AppState): ThreadId[] => {
+    if (!projectId) return EMPTY_THREAD_IDS;
 
+    const projectScopedId = getProjectScopedId({
+      environmentId: state.activeEnvironmentId,
+      id: projectId,
+    });
+    const scopedIds = state.threadScopedIdsByProjectScopedId[projectScopedId] ?? EMPTY_SCOPED_IDS;
+    const sidebarMap = state.sidebarThreadsByScopedId;
+
+    const cached = _threadIdsByProjectCache.get(projectScopedId);
+    if (cached && cached.scopedIds === scopedIds && cached.sidebarMap === sidebarMap) {
+      return cached.result;
+    }
+
+    const result = scopedIds
+      .map((scopedId) => sidebarMap[scopedId]?.id ?? null)
+      .filter((threadId): threadId is ThreadId => threadId !== null);
+
+    _threadIdsByProjectCache.set(projectScopedId, { scopedIds, sidebarMap, result });
+    return result;
+  };
+
 export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
   return updateThreadState(state, state.activeEnvironmentId, threadId, (t) => {
     if (t.error === error) return t;

You can send follow-ups to the cloud agent here.

@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 5, 2026

Approvability

Verdict: Needs human review

2 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Unreachable fallback values in resolveServerUrl
    • Removed the redundant third and fourth arguments from firstNonEmptyString since resolvePrimaryEnvironmentBootstrapUrl() already contains the complete fallback chain and always returns a non-empty string.
  • ✅ Fixed: Cache returns stale null for later-configured remotes
    • Skip caching null identity results so directories without a git remote are re-resolved on subsequent calls, allowing later-configured remotes to be detected.

Create PR

Or push these changes by commenting:

@cursor push ffbf2a1e3c
Preview (ffbf2a1e3c)
diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts
--- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts
+++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts
@@ -115,12 +115,14 @@
     }
 
     const resolved = yield* Effect.promise(() => resolveRepositoryIdentity(cwd));
-    yield* Ref.update(cacheRef, (current) => {
-      const next = new Map(current);
-      next.set(cwd, resolved.identity);
-      next.set(resolved.cacheKey, resolved.identity);
-      return next;
-    });
+    if (resolved.identity !== null) {
+      yield* Ref.update(cacheRef, (current) => {
+        const next = new Map(current);
+        next.set(cwd, resolved.identity);
+        next.set(resolved.cacheKey, resolved.identity);
+        return next;
+      });
+    }
     return resolved.identity;
   });
 

diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -53,12 +53,7 @@
   pathname?: string | undefined;
   searchParams?: Record<string, string> | undefined;
 }): string => {
-  const rawUrl = firstNonEmptyString(
-    options?.url,
-    resolvePrimaryEnvironmentBootstrapUrl(),
-    import.meta.env.VITE_WS_URL,
-    window.location.origin,
-  );
+  const rawUrl = firstNonEmptyString(options?.url, resolvePrimaryEnvironmentBootstrapUrl());
 
   const parsedUrl = new URL(rawUrl);
   if (options?.protocol) {

You can send follow-ups to the cloud agent here.

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Nullish coalescing collapses null into fallback incorrectly
    • Replaced environmentId ?? resolveCurrentEnvironmentId() with environmentId !== undefined ? environmentId : resolveCurrentEnvironmentId() so explicit null is preserved.
  • ✅ Fixed: Multiple exported functions are unused outside definitions
    • Removed scopeThreadSessionRef, resolveEnvironmentClient, tagEnvironmentValue, attachEnvironmentDescriptor, and their supporting types (EnvironmentClientRegistry, EnvironmentScopedRef) from client-runtime.

Create PR

Or push these changes by commenting:

@cursor push 4730c6f1c2
Preview (4730c6f1c2)
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -531,7 +531,10 @@
       try {
         const snapshot = await api.orchestration.getSnapshot();
         if (!disposed) {
-          syncServerReadModel(snapshot, environmentId ?? resolveCurrentEnvironmentId());
+          syncServerReadModel(
+            snapshot,
+            environmentId !== undefined ? environmentId : resolveCurrentEnvironmentId(),
+          );
           reconcileSnapshotDerivedState();
           if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {
             void runReplayRecovery("sequence-gap");

diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/knownEnvironment.ts
--- a/packages/client-runtime/src/knownEnvironment.ts
+++ b/packages/client-runtime/src/knownEnvironment.ts
@@ -1,4 +1,4 @@
-import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
+import type { EnvironmentId } from "@t3tools/contracts";
 
 export interface KnownEnvironmentConnectionTarget {
   readonly type: "ws";
@@ -37,14 +37,3 @@
 ): string | null {
   return environment?.target.wsUrl ?? null;
 }
-
-export function attachEnvironmentDescriptor(
-  environment: KnownEnvironment,
-  descriptor: ExecutionEnvironmentDescriptor,
-): KnownEnvironment {
-  return {
-    ...environment,
-    environmentId: descriptor.environmentId,
-    label: descriptor.label,
-  };
-}

diff --git a/packages/client-runtime/src/scoped.ts b/packages/client-runtime/src/scoped.ts
--- a/packages/client-runtime/src/scoped.ts
+++ b/packages/client-runtime/src/scoped.ts
@@ -2,20 +2,10 @@
   EnvironmentId,
   ProjectId,
   ScopedProjectRef,
-  ScopedThreadSessionRef,
   ScopedThreadRef,
   ThreadId,
 } from "@t3tools/contracts";
 
-interface EnvironmentScopedRef<TId extends string> {
-  readonly environmentId: EnvironmentId;
-  readonly id: TId;
-}
-
-export interface EnvironmentClientRegistry<TClient> {
-  readonly getClient: (environmentId: EnvironmentId) => TClient | null | undefined;
-}
-
 export function scopeProjectRef(
   environmentId: EnvironmentId,
   projectId: ProjectId,
@@ -27,34 +17,7 @@
   return { environmentId, threadId };
 }
 
-export function scopeThreadSessionRef(
-  environmentId: EnvironmentId,
-  threadId: ThreadId,
-): ScopedThreadSessionRef {
-  return { environmentId, threadId };
-}
-
-export function scopedRefKey(
-  ref: EnvironmentScopedRef<string> | ScopedProjectRef | ScopedThreadRef | ScopedThreadSessionRef,
-): string {
-  const localId = "id" in ref ? ref.id : "projectId" in ref ? ref.projectId : ref.threadId;
+export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string {
+  const localId = "projectId" in ref ? ref.projectId : ref.threadId;
   return `${ref.environmentId}:${localId}`;
 }
-
-export function resolveEnvironmentClient<TClient>(
-  registry: EnvironmentClientRegistry<TClient>,
-  ref: EnvironmentScopedRef<string>,
-): TClient {
-  const client = registry.getClient(ref.environmentId);
-  if (!client) {
-    throw new Error(`No client registered for environment ${ref.environmentId}.`);
-  }
-  return client;
-}
-
-export function tagEnvironmentValue<T>(
-  environmentId: EnvironmentId,
-  value: T,
-): { readonly environmentId: EnvironmentId; readonly value: T } {
-  return { environmentId, value };
-}

You can send follow-ups to the cloud agent here.

@juliusmarminge juliusmarminge changed the title Surface execution environment and repository identity metadata Prepare datamodel for multi-environment Apr 6, 2026
@cursor

This comment has been minimized.

@cursor

This comment has been minimized.

@cursor

This comment has been minimized.

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 4177f00

juliusmarminge and others added 2 commits April 7, 2026 12:21
- Skip registering the beforeunload flush unless `window.addEventListener` exists
- Avoid crashes in environments with a partial `window` implementation
…t query layer

OrchestrationProjectionSnapshotQueryLive was self-providing
RepositoryIdentityResolverLive via Layer.provideMerge, creating a
separate resolver instance with its own cache. This duplicated the
top-level instance provided in server.ts, causing redundant git
subprocess calls for the same workspace paths.

Remove the self-provision so the snapshot query layer declares
RepositoryIdentityResolver as an unsatisfied requirement, which is
fulfilled by the single top-level instance. Update all test files
to explicitly provide RepositoryIdentityResolverLive.
@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push f18b15a

cursor bot and others added 2 commits April 7, 2026 19:37
…t query layer

OrchestrationProjectionSnapshotQueryLive was self-providing
RepositoryIdentityResolverLive via Layer.provideMerge, creating a
separate resolver instance with its own cache. This duplicated the
top-level instance provided in server.ts, causing redundant git
subprocess calls for the same workspace paths.

Remove the self-provision so the snapshot query layer declares
RepositoryIdentityResolver as an unsatisfied requirement, which is
fulfilled by the single top-level instance. Update all test files
to explicitly provide RepositoryIdentityResolverLive.

Applied via @cursor push command
- Cache thread jump labels in sidebar state
- Read terminal state inline in chat view selectors
- Avoid rebuilding shortcut maps on every render
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Snapshot enrichment spawns processes inside DB query path
    • Moved repository identity resolution (which spawns git child processes) outside the sql.withTransaction scope so the transaction only covers SQL queries and is released before any subprocess I/O occurs.

Create PR

Or push these changes by commenting:

@cursor push 40b804cb76
Preview (40b804cb76)
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -439,281 +439,283 @@
   const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () =>
     sql
       .withTransaction(
-        Effect.gen(function* () {
-          const [
-            projectRows,
-            threadRows,
-            messageRows,
-            proposedPlanRows,
-            activityRows,
-            sessionRows,
-            checkpointRows,
-            latestTurnRows,
-            stateRows,
-          ] = yield* Effect.all([
-            listProjectRows(undefined).pipe(
-              Effect.mapError(
-                toPersistenceSqlOrDecodeError(
-                  "ProjectionSnapshotQuery.getSnapshot:listProjects:query",
-                  "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows",
-                ),
+        Effect.all([
+          listProjectRows(undefined).pipe(
+            Effect.mapError(
+              toPersistenceSqlOrDecodeError(
+                "ProjectionSnapshotQuery.getSnapshot:listProjects:query",
+                "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows",
               ),
             ),
-            listThreadRows(undefined).pipe(
-              Effect.mapError(
-                toPersistenceSqlOrDecodeError(
-                  "ProjectionSnapshotQuery.getSnapshot:listThreads:query",
-                  "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows",
-                ),
+          ),
+          listThreadRows(undefined).pipe(
+            Effect.mapError(
+              toPersistenceSqlOrDecodeError(
+                "ProjectionSnapshotQuery.getSnapshot:listThreads:query",
+                "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows",
               ),
             ),
-            listThreadMessageRows(undefined).pipe(
-              Effect.mapError(
-                toPersistenceSqlOrDecodeError(
-                  "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query",
-                  "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows",
-                ),
+          ),
+          listThreadMessageRows(undefined).pipe(
+            Effect.mapError(
+              toPersistenceSqlOrDecodeError(
+                "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query",
+                "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows",
               ),
             ),
-            listThreadProposedPlanRows(undefined).pipe(
-              Effect.mapError(
-                toPersistenceSqlOrDecodeError(
-                  "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query",
-                  "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows",
-                ),
+          ),
+          listThreadProposedPlanRows(undefined).pipe(
+            Effect.mapError(
+              toPersistenceSqlOrDecodeError(
+                "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query",
+                "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows",
               ),
             ),
-            listThreadActivityRows(undefined).pipe(
-              Effect.mapError(
-                toPersistenceSqlOrDecodeError(
-                  "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query",
-                  "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows",
-                ),
+          ),
+          listThreadActivityRows(undefined).pipe(
+            Effect.mapError(
+              toPersistenceSqlOrDecodeError(
+                "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query",
+                "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows",
               ),
             ),
-            listThreadSessionRows(undefined).pipe(
-              Effect.mapError(
-                toPersistenceSqlOrDecodeError(
-                  "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query",
-                  "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows",
-                ),
+          ),
+          listThreadSessionRows(undefined).pipe(
+            Effect.mapError(
+              toPersistenceSqlOrDecodeError(
+                "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query",
+                "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows",
               ),
             ),
-            listCheckpointRows(undefined).pipe(
-              Effect.mapError(
-                toPersistenceSqlOrDecodeError(
-                  "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query",
-                  "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows",
-                ),
+          ),
+          listCheckpointRows(undefined).pipe(
+            Effect.mapError(
+              toPersistenceSqlOrDecodeError(
+                "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query",
+                "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows",
               ),
             ),
-            listLatestTurnRows(undefined).pipe(
-              Effect.mapError(
-                toPersistenceSqlOrDecodeError(
-                  "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query",
-                  "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows",
-                ),
+          ),
+          listLatestTurnRows(undefined).pipe(
+            Effect.mapError(
+              toPersistenceSqlOrDecodeError(
+                "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query",
+                "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows",
               ),
             ),
-            listProjectionStateRows(undefined).pipe(
-              Effect.mapError(
-                toPersistenceSqlOrDecodeError(
-                  "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query",
-                  "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows",
-                ),
+          ),
+          listProjectionStateRows(undefined).pipe(
+            Effect.mapError(
+              toPersistenceSqlOrDecodeError(
+                "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query",
+                "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows",
               ),
             ),
-          ]);
+          ),
+        ]),
+      )
+      .pipe(
+        Effect.flatMap(
+          ([
+            projectRows,
+            threadRows,
+            messageRows,
+            proposedPlanRows,
+            activityRows,
+            sessionRows,
+            checkpointRows,
+            latestTurnRows,
+            stateRows,
+          ]) =>
+            Effect.gen(function* () {
+              const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
+              const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
+              const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
+              const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
+              const sessionsByThread = new Map<string, OrchestrationSession>();
+              const latestTurnByThread = new Map<string, OrchestrationLatestTurn>();
 
-          const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
-          const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
-          const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
-          const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
-          const sessionsByThread = new Map<string, OrchestrationSession>();
-          const latestTurnByThread = new Map<string, OrchestrationLatestTurn>();
+              let updatedAt: string | null = null;
 
-          let updatedAt: string | null = null;
+              for (const row of projectRows) {
+                updatedAt = maxIso(updatedAt, row.updatedAt);
+              }
+              for (const row of threadRows) {
+                updatedAt = maxIso(updatedAt, row.updatedAt);
+              }
+              for (const row of stateRows) {
+                updatedAt = maxIso(updatedAt, row.updatedAt);
+              }
 
-          for (const row of projectRows) {
-            updatedAt = maxIso(updatedAt, row.updatedAt);
-          }
-          for (const row of threadRows) {
-            updatedAt = maxIso(updatedAt, row.updatedAt);
-          }
-          for (const row of stateRows) {
-            updatedAt = maxIso(updatedAt, row.updatedAt);
-          }
+              for (const row of messageRows) {
+                updatedAt = maxIso(updatedAt, row.updatedAt);
+                const threadMessages = messagesByThread.get(row.threadId) ?? [];
+                threadMessages.push({
+                  id: row.messageId,
+                  role: row.role,
+                  text: row.text,
+                  ...(row.attachments !== null ? { attachments: row.attachments } : {}),
+                  turnId: row.turnId,
+                  streaming: row.isStreaming === 1,
+                  createdAt: row.createdAt,
+                  updatedAt: row.updatedAt,
+                });
+                messagesByThread.set(row.threadId, threadMessages);
+              }
 
-          for (const row of messageRows) {
-            updatedAt = maxIso(updatedAt, row.updatedAt);
-            const threadMessages = messagesByThread.get(row.threadId) ?? [];
-            threadMessages.push({
-              id: row.messageId,
-              role: row.role,
-              text: row.text,
-              ...(row.attachments !== null ? { attachments: row.attachments } : {}),
-              turnId: row.turnId,
-              streaming: row.isStreaming === 1,
-              createdAt: row.createdAt,
-              updatedAt: row.updatedAt,
-            });
-            messagesByThread.set(row.threadId, threadMessages);
-          }
+              for (const row of proposedPlanRows) {
+                updatedAt = maxIso(updatedAt, row.updatedAt);
+                const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? [];
+                threadProposedPlans.push({
+                  id: row.planId,
+                  turnId: row.turnId,
+                  planMarkdown: row.planMarkdown,
+                  implementedAt: row.implementedAt,
+                  implementationThreadId: row.implementationThreadId,
+                  createdAt: row.createdAt,
+                  updatedAt: row.updatedAt,
+                });
+                proposedPlansByThread.set(row.threadId, threadProposedPlans);
+              }
 
-          for (const row of proposedPlanRows) {
-            updatedAt = maxIso(updatedAt, row.updatedAt);
-            const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? [];
-            threadProposedPlans.push({
-              id: row.planId,
-              turnId: row.turnId,
-              planMarkdown: row.planMarkdown,
-              implementedAt: row.implementedAt,
-              implementationThreadId: row.implementationThreadId,
-              createdAt: row.createdAt,
-              updatedAt: row.updatedAt,
-            });
-            proposedPlansByThread.set(row.threadId, threadProposedPlans);
-          }
+              for (const row of activityRows) {
+                updatedAt = maxIso(updatedAt, row.createdAt);
+                const threadActivities = activitiesByThread.get(row.threadId) ?? [];
+                threadActivities.push({
+                  id: row.activityId,
+                  tone: row.tone,
+                  kind: row.kind,
+                  summary: row.summary,
+                  payload: row.payload,
+                  turnId: row.turnId,
+                  ...(row.sequence !== null ? { sequence: row.sequence } : {}),
+                  createdAt: row.createdAt,
+                });
+                activitiesByThread.set(row.threadId, threadActivities);
+              }
 
-          for (const row of activityRows) {
-            updatedAt = maxIso(updatedAt, row.createdAt);
-            const threadActivities = activitiesByThread.get(row.threadId) ?? [];
-            threadActivities.push({
-              id: row.activityId,
-              tone: row.tone,
-              kind: row.kind,
-              summary: row.summary,
-              payload: row.payload,
-              turnId: row.turnId,
-              ...(row.sequence !== null ? { sequence: row.sequence } : {}),
-              createdAt: row.createdAt,
-            });
-            activitiesByThread.set(row.threadId, threadActivities);
-          }
+              for (const row of checkpointRows) {
+                updatedAt = maxIso(updatedAt, row.completedAt);
+                const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? [];
+                threadCheckpoints.push({
+                  turnId: row.turnId,
+                  checkpointTurnCount: row.checkpointTurnCount,
+                  checkpointRef: row.checkpointRef,
+                  status: row.status,
+                  files: row.files,
+                  assistantMessageId: row.assistantMessageId,
+                  completedAt: row.completedAt,
+                });
+                checkpointsByThread.set(row.threadId, threadCheckpoints);
+              }
 
-          for (const row of checkpointRows) {
-            updatedAt = maxIso(updatedAt, row.completedAt);
-            const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? [];
-            threadCheckpoints.push({
-              turnId: row.turnId,
-              checkpointTurnCount: row.checkpointTurnCount,
-              checkpointRef: row.checkpointRef,
-              status: row.status,
-              files: row.files,
-              assistantMessageId: row.assistantMessageId,
-              completedAt: row.completedAt,
-            });
-            checkpointsByThread.set(row.threadId, threadCheckpoints);
-          }
+              for (const row of latestTurnRows) {
+                updatedAt = maxIso(updatedAt, row.requestedAt);
+                if (row.startedAt !== null) {
+                  updatedAt = maxIso(updatedAt, row.startedAt);
+                }
+                if (row.completedAt !== null) {
+                  updatedAt = maxIso(updatedAt, row.completedAt);
+                }
+                if (latestTurnByThread.has(row.threadId)) {
+                  continue;
+                }
+                latestTurnByThread.set(row.threadId, {
+                  turnId: row.turnId,
+                  state:
+                    row.state === "error"
+                      ? "error"
+                      : row.state === "interrupted"
+                        ? "interrupted"
+                        : row.state === "completed"
+                          ? "completed"
+                          : "running",
+                  requestedAt: row.requestedAt,
+                  startedAt: row.startedAt,
+                  completedAt: row.completedAt,
+                  assistantMessageId: row.assistantMessageId,
+                  ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null
+                    ? {
+                        sourceProposedPlan: {
+                          threadId: row.sourceProposedPlanThreadId,
+                          planId: row.sourceProposedPlanId,
+                        },
+                      }
+                    : {}),
+                });
+              }
 
-          for (const row of latestTurnRows) {
-            updatedAt = maxIso(updatedAt, row.requestedAt);
-            if (row.startedAt !== null) {
-              updatedAt = maxIso(updatedAt, row.startedAt);
-            }
-            if (row.completedAt !== null) {
-              updatedAt = maxIso(updatedAt, row.completedAt);
-            }
-            if (latestTurnByThread.has(row.threadId)) {
-              continue;
-            }
-            latestTurnByThread.set(row.threadId, {
-              turnId: row.turnId,
-              state:
-                row.state === "error"
-                  ? "error"
-                  : row.state === "interrupted"
-                    ? "interrupted"
-                    : row.state === "completed"
-                      ? "completed"
-                      : "running",
-              requestedAt: row.requestedAt,
-              startedAt: row.startedAt,
-              completedAt: row.completedAt,
-              assistantMessageId: row.assistantMessageId,
-              ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null
-                ? {
-                    sourceProposedPlan: {
-                      threadId: row.sourceProposedPlanThreadId,
-                      planId: row.sourceProposedPlanId,
-                    },
-                  }
-                : {}),
-            });
-          }
+              for (const row of sessionRows) {
+                updatedAt = maxIso(updatedAt, row.updatedAt);
+                sessionsByThread.set(row.threadId, {
+                  threadId: row.threadId,
+                  status: row.status,
+                  providerName: row.providerName,
+                  runtimeMode: row.runtimeMode,
+                  activeTurnId: row.activeTurnId,
+                  lastError: row.lastError,
+                  updatedAt: row.updatedAt,
+                });
+              }
 
-          for (const row of sessionRows) {
-            updatedAt = maxIso(updatedAt, row.updatedAt);
-            sessionsByThread.set(row.threadId, {
-              threadId: row.threadId,
-              status: row.status,
-              providerName: row.providerName,
-              runtimeMode: row.runtimeMode,
-              activeTurnId: row.activeTurnId,
-              lastError: row.lastError,
-              updatedAt: row.updatedAt,
-            });
-          }
+              const repositoryIdentities = new Map(
+                yield* Effect.forEach(
+                  projectRows,
+                  (row) =>
+                    repositoryIdentityResolver
+                      .resolve(row.workspaceRoot)
+                      .pipe(Effect.map((identity) => [row.projectId, identity] as const)),
+                  { concurrency: repositoryIdentityResolutionConcurrency },
+                ),
+              );
 
-          const repositoryIdentities = new Map(
-            yield* Effect.forEach(
-              projectRows,
-              (row) =>
-                repositoryIdentityResolver
-                  .resolve(row.workspaceRoot)
-                  .pipe(Effect.map((identity) => [row.projectId, identity] as const)),
-              { concurrency: repositoryIdentityResolutionConcurrency },
-            ),
-          );
+              const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
+                id: row.projectId,
+                title: row.title,
+                workspaceRoot: row.workspaceRoot,
+                repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
+                defaultModelSelection: row.defaultModelSelection,
+                scripts: row.scripts,
+                createdAt: row.createdAt,
+                updatedAt: row.updatedAt,
+                deletedAt: row.deletedAt,
+              }));
 
-          const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
-            id: row.projectId,
-            title: row.title,
-            workspaceRoot: row.workspaceRoot,
-            repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
-            defaultModelSelection: row.defaultModelSelection,
-            scripts: row.scripts,
-            createdAt: row.createdAt,
-            updatedAt: row.updatedAt,
-            deletedAt: row.deletedAt,
-          }));
+              const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
+                id: row.threadId,
+                projectId: row.projectId,
+                title: row.title,
+                modelSelection: row.modelSelection,
+                runtimeMode: row.runtimeMode,
+                interactionMode: row.interactionMode,
+                branch: row.branch,
+                worktreePath: row.worktreePath,
+                latestTurn: latestTurnByThread.get(row.threadId) ?? null,
+                createdAt: row.createdAt,
+                updatedAt: row.updatedAt,
+                archivedAt: row.archivedAt,
+                deletedAt: row.deletedAt,
+                messages: messagesByThread.get(row.threadId) ?? [],
+                proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
+                activities: activitiesByThread.get(row.threadId) ?? [],
+                checkpoints: checkpointsByThread.get(row.threadId) ?? [],
+                session: sessionsByThread.get(row.threadId) ?? null,
+              }));
 
-          const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
-            id: row.threadId,
-            projectId: row.projectId,
-            title: row.title,
-            modelSelection: row.modelSelection,
-            runtimeMode: row.runtimeMode,
-            interactionMode: row.interactionMode,
-            branch: row.branch,
-            worktreePath: row.worktreePath,
-            latestTurn: latestTurnByThread.get(row.threadId) ?? null,
-            createdAt: row.createdAt,
-            updatedAt: row.updatedAt,
-            archivedAt: row.archivedAt,
-            deletedAt: row.deletedAt,
-            messages: messagesByThread.get(row.threadId) ?? [],
-            proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
-            activities: activitiesByThread.get(row.threadId) ?? [],
-            checkpoints: checkpointsByThread.get(row.threadId) ?? [],
-            session: sessionsByThread.get(row.threadId) ?? null,
-          }));
+              const snapshot = {
+                snapshotSequence: computeSnapshotSequence(stateRows),
+                projects,
+                threads,
+                updatedAt: updatedAt ?? new Date(0).toISOString(),
+              };
 
-          const snapshot = {
-            snapshotSequence: computeSnapshotSequence(stateRows),
-            projects,
-            threads,
-            updatedAt: updatedAt ?? new Date(0).toISOString(),
-          };
-
-          return yield* decodeReadModel(snapshot).pipe(
-            Effect.mapError(
-              toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"),
-            ),
-          );
-        }),
-      )
-      .pipe(
+              return yield* decodeReadModel(snapshot).pipe(
+                Effect.mapError(
+                  toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"),
+                ),
+              );
+            }),
+        ),
         Effect.mapError((error) => {
           if (isPersistenceError(error)) {
             return error;

You can send follow-ups to the cloud agent here.

…tSnapshot

The repositoryIdentityResolver.resolve calls spawn git child processes,
which should not run inside a database transaction scope. This change
restructures getSnapshot so the transaction only covers the SQL queries,
and the repository identity resolution + snapshot assembly happens after
the transaction completes via Effect.flatMap.
- Use deferred mutation promises to control toast lifecycle
- Simplify test mocks and reset deferred state between cases
@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 40b804c

…tSnapshot

The repositoryIdentityResolver.resolve calls spawn git child processes,
which should not run inside a database transaction scope. This change
restructures getSnapshot so the transaction only covers the SQL queries,
and the repository identity resolution + snapshot assembly happens after
the transaction completes via Effect.flatMap.

Applied via @cursor push command
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Draft branch changes silently ignored without draftId prop
    • Replaced the silent early return with a fallback to threadRef (always available) when draftId is absent, restoring the original behavior where setDraftThreadContext was called with threadRef.

Create PR

Or push these changes by commenting:

@cursor push 1de72b65d5
Preview (1de72b65d5)
diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx
--- a/apps/web/src/components/BranchToolbar.tsx
+++ b/apps/web/src/components/BranchToolbar.tsx
@@ -105,10 +105,7 @@
         currentWorktreePath: activeWorktreePath,
         effectiveEnvMode,
       });
-      if (!draftId) {
-        return;
-      }
-      setDraftThreadContext(draftId, {
+      setDraftThreadContext(draftId ?? threadRef, {
         branch,
         worktreePath,
         envMode: nextDraftEnvMode,
@@ -124,6 +121,7 @@
       setThreadBranchAction,
       setDraftThreadContext,
       draftId,
+      threadRef,
       environmentId,
       effectiveEnvMode,
     ],

You can send follow-ups to the cloud agent here.

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 1de72b6

When BranchToolbar is rendered for a draft thread without a draftId prop,
selecting a branch silently discarded the user's selection due to an early
return guard. Restore the original behavior by falling back to threadRef
(which is always available) when draftId is not provided.

Applied via @cursor push command
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Snapshot resolution spawns git subprocesses for deleted projects
    • Filtered projectRows to only active (non-deleted) projects before the Effect.forEach that resolves repository identities via git subprocesses; deleted projects already fall back to repositoryIdentity: null via the existing ?? null default.

Create PR

Or push these changes by commenting:

@cursor push 3f3542a048
Preview (3f3542a048)
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -658,9 +658,10 @@
                 });
               }
 
+              const activeProjectRows = projectRows.filter((row) => row.deletedAt === null);
               const repositoryIdentities = new Map(
                 yield* Effect.forEach(
-                  projectRows,
+                  activeProjectRows,
                   (row) =>
                     repositoryIdentityResolver
                       .resolve(row.workspaceRoot)

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 18b9087. Configure here.

@juliusmarminge juliusmarminge changed the base branch from t3code/pr-1708/web/atomic-store-refactor to main April 9, 2026 00:33
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge force-pushed the t3code/remote-host-model branch from 6215701 to 3481ed8 Compare April 9, 2026 01:07
const REPLAY_RECOVERY_RETRY_DELAY_MS = 100;
const MAX_NO_PROGRESS_REPLAY_RETRIES = 3;

function useRegisteredWsRpcClientEntries(): ReadonlyArray<WsRpcClientEntry> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low routes/__root.tsx:223

useRegisteredWsRpcClientEntries returns a new array reference on every render because both listWsRpcClientEntries() and the fallback [getPrimaryWsRpcClientEntry()] allocate fresh arrays. This causes EventRouter's main useEffect (line 726) to re-run on every EventRouter re-render, tearing down and re-establishing all subscriptions unnecessarily, which can drop events during transition and degrade performance. Consider memoizing the result with useMemo keyed on the revision state.

-function useRegisteredWsRpcClientEntries(): ReadonlyArray<WsRpcClientEntry> {
-  const [, setRevision] = useState(0);
-
-  useEffect(() => subscribeWsRpcClientRegistry(() => setRevision((value) => value + 1)), []);
-
-  const entries = listWsRpcClientEntries();
-  return entries.length > 0 ? entries : [getPrimaryWsRpcClientEntry()];
-}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/routes/__root.tsx around line 223:

`useRegisteredWsRpcClientEntries` returns a new array reference on every render because both `listWsRpcClientEntries()` and the fallback `[getPrimaryWsRpcClientEntry()]` allocate fresh arrays. This causes `EventRouter`'s main `useEffect` (line 726) to re-run on every `EventRouter` re-render, tearing down and re-establishing all subscriptions unnecessarily, which can drop events during transition and degrade performance. Consider memoizing the result with `useMemo` keyed on the revision state.

Evidence trail:
apps/web/src/routes/__root.tsx lines 224-230 (`useRegisteredWsRpcClientEntries` hook definition showing direct call to `listWsRpcClientEntries()` without memoization)
apps/web/src/wsRpcClient.ts line 167 (`listWsRpcClientEntries` returns `[...wsRpcClientEntriesByKey.values()].map(toReadonlyEntry)` - new array each call)
apps/web/src/routes/__root.tsx line 262 (`clientEntries = useRegisteredWsRpcClientEntries()`)
apps/web/src/routes/__root.tsx lines 724-738 (useEffect dependency array containing `clientEntries`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

enabled: input.cwd !== null && (input.enabled ?? true),

The enabled condition at line 89 only checks input.cwd !== null but the queryFn throws when input.environmentId is null. When cwd is non-null but environmentId is null, the query is enabled and immediately errors. Include input.environmentId !== null in the enabled condition to prevent the query from running with invalid input.

Also found in 1 other location(s)

apps/web/src/localApi.ts:74

In readLocalApi(), when window.nativeApi is undefined (non-Electron browser environment), calling createLocalApi() without arguments triggers the default parameter getPrimaryWsRpcClientEntry().client. If no primary websocket environment is configured yet, getPrimaryWsRpcClientEntry() throws an Error(&#34;Unable to resolve the primary websocket environment.&#34;). This causes readLocalApi() to throw instead of returning undefined, which contradicts its return type LocalApi | undefined and breaks callers like ThreadTerminalDrawer.tsx that check if (!localApi) return; expecting graceful undefined rather than an exception.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/lib/gitReactQuery.ts around line 89:

The `enabled` condition at line 89 only checks `input.cwd !== null` but the `queryFn` throws when `input.environmentId` is null. When `cwd` is non-null but `environmentId` is null, the query is enabled and immediately errors. Include `input.environmentId !== null` in the `enabled` condition to prevent the query from running with invalid input.

Evidence trail:
apps/web/src/lib/gitReactQuery.ts lines 66-93 at REVIEWED_COMMIT: function `gitBranchSearchInfiniteQueryOptions` has input type `environmentId: EnvironmentId | null` (line 68), `queryFn` throws at line 79 when `!input.environmentId`, but `enabled` at line 89 only checks `input.cwd !== null && (input.enabled ?? true)` without checking `environmentId`.

Also found in 1 other location(s):
- apps/web/src/localApi.ts:74 -- In `readLocalApi()`, when `window.nativeApi` is undefined (non-Electron browser environment), calling `createLocalApi()` without arguments triggers the default parameter `getPrimaryWsRpcClientEntry().client`. If no primary websocket environment is configured yet, `getPrimaryWsRpcClientEntry()` throws an `Error("Unable to resolve the primary websocket environment.")`. This causes `readLocalApi()` to throw instead of returning `undefined`, which contradicts its return type `LocalApi | undefined` and breaks callers like `ThreadTerminalDrawer.tsx` that check `if (!localApi) return;` expecting graceful `undefined` rather than an exception.

- Keep the latest `onAdvance` in a ref for delayed selection handling
- Avoid resubscribing the option toggle handler when only the callback changes
- Replace the callback with `useEffectEvent` so option selection sees fresh state without extra deps
- Keep keyboard auto-advance behavior unchanged
@juliusmarminge juliusmarminge enabled auto-merge (squash) April 9, 2026 02:02
@juliusmarminge juliusmarminge merged commit b96308f into main Apr 9, 2026
12 checks passed
@juliusmarminge juliusmarminge deleted the t3code/remote-host-model branch April 9, 2026 02:05
@tarik02
Copy link
Copy Markdown

tarik02 commented Apr 9, 2026

@juliusmarminge

apps/web/index.html now includes:

<script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>

But bun run dist:desktop:linux packages a self-contained client and scripts/build-desktop-artifact.ts rejects that external reference, failing with:

  Bundled client references missing files in apps/server/dist/client/index.html:
   //unpkg.com/react-scan/dist/auto.global.js

Regular CI doesn’t catch it because it only runs bun run build:desktop, not the release artifact build.

Was react-scan meant to be dev-only? That seems like the intended fix.

@juliusmarminge
Copy link
Copy Markdown
Member Author

Was meant to merge as stack with #1768 but ran into some issues. Fixing. Be patient

rororowyourboat added a commit to rororowyourboat/t3code that referenced this pull request Apr 9, 2026
…threadId (#2)

* Raise slow RPC ack warning threshold to 15s (pingdotgg#1760)

* Use active worktree path for workspace saves (pingdotgg#1762)

* Stream git status updates over WebSocket (pingdotgg#1763)

Co-authored-by: codex <codex@users.noreply.github.com>

* fix(web): unwrap windows shell command wrappers (pingdotgg#1719)

* Rename "Chat" to "Build" in interaction mode toggle (pingdotgg#1769)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

* Assign default capabilities to Codex custom models (pingdotgg#1793)

* Add project rename support in the sidebar (pingdotgg#1798)

* Support multi-select pending user inputs (pingdotgg#1797)

* Add Zed support to Open actions via editor command aliases (pingdotgg#1303)

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>

* Closes pingdotgg#1795 - Support building and developing in a devcontainer (pingdotgg#1791)

* Add explicit timeouts to CI and release workflows (pingdotgg#1825)

* fix(web): distinguish singular/plural in pending action submit label (pingdotgg#1826)

* Refactor web stores into atomic slices ready to split ChatView (pingdotgg#1708)

* Add VSCode Insiders and VSCodium icons (pingdotgg#1847)

* Prepare datamodel for multi-environment (pingdotgg#1765)

Co-authored-by: justsomelegs <145564979+justsomelegs@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>

* Implement server auth bootstrap and pairing flow (pingdotgg#1768)

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>

* Use dev proxy for loopback auth and environment requests (pingdotgg#1853)

* Refresh local git status on turn completion (pingdotgg#1821)

Co-authored-by: codex <codex@users.noreply.github.com>

* fix(desktop): add Copy Link action for chat links (pingdotgg#1835)

* fix: map runtime modes to correct permission levels (pingdotgg#1587)

Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>

* Fix persisted composer image hydration typo (pingdotgg#1831)

* Clarify environment and workspace picker labels (pingdotgg#1854)

* Scope git toast state by thread ref (pingdotgg#1855)

* fix build (pingdotgg#1859)

* Stabilize keybindings toast stream setup (pingdotgg#1860)

Co-authored-by: Julius Marminge <julius@macmini.local>

* feat(web): add embeddable thread route for canvas tile hosts

Adds /embed/thread/:environmentId/:threadId — a standalone route that
renders the existing ChatView without the app sidebar chrome. This is the
iframe target for t3-canvas agent shapes (see rororowyourboat/t3-canvas#3).

- New file-based route embed.thread.\$environmentId.\$threadId.tsx
- __root.tsx bypasses AppSidebarLayout for any /embed/* pathname so the
  environment connection + websocket surface + toasts still initialize
  but the sidebar/diff/plan chrome does not render
- minimal=1 search param is parsed and wired to a data attribute on the
  container for future targeted CSS; chrome hiding (BranchToolbar,
  PlanSidebar, ThreadTerminalDrawer) stays as a follow-up pass
- routeTree.gen.ts regenerated by the @tanstack/router-plugin

---------

Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: legs <145564979+justsomelegs@users.noreply.github.com>
Co-authored-by: sonder <168988030+heysonder@users.noreply.github.com>
Co-authored-by: Adem Ben Abdallah <96244394+AdemBenAbdallah@users.noreply.github.com>
Co-authored-by: Kyle Gottfried <6462596+Spitfire1900@users.noreply.github.com>
Co-authored-by: Jacob <589761+jvzijp@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Klemencina <56873773+Klemencina@users.noreply.github.com>
Co-authored-by: Oskar Sekutowicz <me.oski646@gmail.com>
Co-authored-by: Noxire <59626436+noxire-dev@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants