Skip to content

Prepare datamodel for multi-environment#1765

Open
juliusmarminge wants to merge 24 commits intot3code/pr-1708/web/atomic-store-refactorfrom
t3code/remote-host-model
Open

Prepare datamodel for multi-environment#1765
juliusmarminge wants to merge 24 commits intot3code/pr-1708/web/atomic-store-refactorfrom
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

Medium Risk
Touches core server handshake/websocket payloads and read-model projection by adding environment descriptors and repository identity enrichment, which could affect client compatibility and event ordering. Repository identity resolution shells out to git and is cached, so failures/perf regressions are plausible in varied repo setups.

Overview
Adds first-class environment metadata to support multi-environment bootstrapping: the server now persists a stable environmentId to disk and exposes an ExecutionEnvironmentDescriptor (label/platform/serverVersion/capabilities), which is included in server config and lifecycle welcome/ready events.

Introduces a RepositoryIdentityResolver that derives a normalized repository identity from git remotes (with TTL caching), and threads that identity into orchestration projections (getSnapshot/getActiveProjectByWorkspaceRoot) and websocket event streams/replays for project events.

Desktop plumbing is extended with a new IPC bridge method (getLocalEnvironmentBootstrap) so the web UI can bootstrap the “Local environment” with its websocket URL; associated tests/timeouts are updated and additional test coverage is added for environment-id persistence, repository identity resolution, and event enrichment.

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

Note

Scope app state, routing, and APIs by environment to support multi-environment connections

  • Introduces a new @t3tools/client-runtime package with KnownEnvironment, ScopedProjectRef, ScopedThreadRef, and scoped key helpers used throughout the web app.
  • Refactors AppState in store.ts to hold activeEnvironmentId and a environmentStateById map, isolating projects, threads, and orchestration events per environment.
  • Replaces the global NativeApi/WsRpcClient singletons with a registry of WsRpcClientEntry objects keyed by KnownEnvironment, and introduces LocalApi for browser/OS interactions vs. EnvironmentApi for backend-scoped calls.
  • Adds a ServerEnvironment service on the server that generates and persists a stable environmentId, and attaches an ExecutionEnvironmentDescriptor (including platform, version, and capabilities) to startup, heartbeat, and WS snapshot payloads.
  • Enriches project.created and project.meta-updated orchestration events with repositoryIdentity resolved from git remotes via a new cached RepositoryIdentityResolver.
  • Splits the chat thread route from /_chat/$threadId to /_chat/$environmentId/$threadId for server threads and /_chat/draft/$threadId for draft threads; route helpers buildThreadRouteParams, resolveThreadRouteRef, and resolveThreadRouteTarget are added in threadRoutes.ts.
  • Migrates terminal, thread-selection, and composer-draft stores to use scoped string keys (environmentId:threadId) instead of branded ThreadId values; persisted composer drafts bump to schema v5 with migration.
  • Risk: URL structure for existing server chat thread links changes (adds environmentId segment), breaking any bookmarked or deep-linked URLs.

Macroscope summarized 583f97c.

@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: 467b9935-dec2-4228-ba29-2b8ba6c2fec5

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

@cursor
Copy link
Copy Markdown
Contributor

cursor bot commented Apr 6, 2026

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

  • ✅ Fixed: Sidebar multi-select scans all threads instead of direct lookup
    • Restored the O(1) sidebarThreadById map (keyed by bare ThreadId, scoped to the active environment's visible threads) for use in handleMultiSelectContextMenu, replacing the O(n) linear scan over sidebarThreadByScopedId.values().

Create PR

Or push these changes by commenting:

@cursor push b666cde275
Preview (b666cde275)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -58,7 +58,7 @@
 import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
 import { isTerminalFocused } from "../lib/terminalFocus";
 import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
-import { getProjectScopedId, getThreadScopedId, useStore } from "../store";
+import { getProjectScopedId, useStore } from "../store";
 import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
 import { useUiStateStore } from "../uiStateStore";
 import {
@@ -785,14 +785,8 @@
         .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined),
     [sidebarThreadsByScopedId, threadScopedIdsByProjectScopedId],
   );
-  const sidebarThreadByScopedId = useMemo(
-    () =>
-      new Map(
-        sidebarThreads.map((thread) => [
-          getThreadScopedId({ environmentId: thread.environmentId, id: thread.id }),
-          thread,
-        ]),
-      ),
+  const sidebarThreadById = useMemo(
+    () => new Map(sidebarThreads.map((thread) => [thread.id, thread] as const)),
     [sidebarThreads],
   );
   const routeTerminalOpen = routeThreadId
@@ -1144,13 +1138,7 @@
 
       if (clicked === "mark-unread") {
         for (const id of ids) {
-          let thread: SidebarThreadSummary | undefined;
-          for (const t of sidebarThreadByScopedId.values()) {
-            if (t.id === id) {
-              thread = t;
-              break;
-            }
-          }
+          const thread = sidebarThreadById.get(id);
           markThreadUnread(id, thread?.latestTurn?.completedAt);
         }
         clearSelection();
@@ -1181,7 +1169,7 @@
       deleteThread,
       markThreadUnread,
       removeFromSelection,
-      sidebarThreadByScopedId,
+      sidebarThreadById,
       selectedThreadIds,
     ],
   );

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 1 potential issue.

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

Autofix Details

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

  • ✅ Fixed: Sidebar renderedThreadIds diverges from renderedThreads usage
    • Removed the dead renderedThreadIds computation from the renderedProjects memo and updated getVisibleSidebarThreadIds to derive thread IDs from renderedThreads instead.

Create PR

Or push these changes by commenting:

@cursor push b3888a4824
Preview (b3888a4824)
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -326,14 +326,17 @@
     expect(
       getVisibleSidebarThreadIds([
         {
-          renderedThreadIds: [
-            ThreadId.makeUnsafe("thread-12"),
-            ThreadId.makeUnsafe("thread-11"),
-            ThreadId.makeUnsafe("thread-10"),
+          renderedThreads: [
+            { id: ThreadId.makeUnsafe("thread-12") },
+            { id: ThreadId.makeUnsafe("thread-11") },
+            { id: ThreadId.makeUnsafe("thread-10") },
           ],
         },
         {
-          renderedThreadIds: [ThreadId.makeUnsafe("thread-8"), ThreadId.makeUnsafe("thread-6")],
+          renderedThreads: [
+            { id: ThreadId.makeUnsafe("thread-8") },
+            { id: ThreadId.makeUnsafe("thread-6") },
+          ],
         },
       ]),
     ).toEqual([
@@ -350,14 +353,17 @@
       getVisibleSidebarThreadIds([
         {
           shouldShowThreadPanel: false,
-          renderedThreadIds: [
-            ThreadId.makeUnsafe("thread-hidden-2"),
-            ThreadId.makeUnsafe("thread-hidden-1"),
+          renderedThreads: [
+            { id: ThreadId.makeUnsafe("thread-hidden-2") },
+            { id: ThreadId.makeUnsafe("thread-hidden-1") },
           ],
         },
         {
           shouldShowThreadPanel: true,
-          renderedThreadIds: [ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")],
+          renderedThreads: [
+            { id: ThreadId.makeUnsafe("thread-12") },
+            { id: ThreadId.makeUnsafe("thread-11") },
+          ],
         },
       ]),
     ).toEqual([ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")]);

diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -230,14 +230,16 @@
   return [...ordered, ...remaining];
 }
 
-export function getVisibleSidebarThreadIds<TThreadId>(
+export function getVisibleSidebarThreadIds<TThread extends { id: unknown }>(
   renderedProjects: readonly {
     shouldShowThreadPanel?: boolean;
-    renderedThreadIds: readonly TThreadId[];
+    renderedThreads: readonly TThread[];
   }[],
-): TThreadId[] {
+): TThread["id"][] {
   return renderedProjects.flatMap((renderedProject) =>
-    renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds,
+    renderedProject.shouldShowThreadPanel === false
+      ? []
+      : renderedProject.renderedThreads.map((thread) => thread.id),
   );
 }
 

diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -1435,9 +1435,6 @@
         const renderedThreads = pinnedCollapsedThread
           ? [pinnedCollapsedThread]
           : visibleProjectThreads;
-        const renderedThreadIds = pinnedCollapsedThread
-          ? [pinnedCollapsedThread.id]
-          : visibleProjectThreads.map((thread) => thread.id);
         const showEmptyThreadState = project.expanded && projectThreads.length === 0;
 
         return {
@@ -1447,7 +1444,6 @@
           project,
           projectStatus,
           renderedThreads,
-          renderedThreadIds,
           showEmptyThreadState,
           shouldShowThreadPanel,
           isThreadListExpanded,

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

- Persist a stable server environment ID and descriptor
- Resolve repository identity from git remotes and enrich orchestration events
- Thread environment metadata through desktop and web startup flows
- Add packages/client-runtime/package.json to the release smoke workspace list
- keep remote-host project/thread lookups separate from the active env
- avoid double-enriching replayed project events
juliusmarminge and others added 10 commits April 6, 2026 13:44
- Make active environment mandatory in store and read-model sync
- Scope chat, sidebar, and event replay state by resolved environment
- Update tests for environment-aware thread and project handling
- keep untouched environment sidebar entries and project thread indexes stable during local snapshot sync
- avoid eagerly resolving bootstrap URLs when an explicit server URL is provided
- tighten scoped helpers by removing unused environment/session utilities
- add TTL-backed positive and negative caching for repository identity resolution
- refresh identities when remotes appear or change after cache expiry
- cover late-remote and remote-change cases in tests
- Strip explicit ports from URL-style git remotes
- Add regression coverage for HTTPS and SSH remotes
- Preserve persisted environment IDs on read failures
- Scope sidebar thread lookups to the active environment
- Treat empty server URLs as unset
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge force-pushed the t3code/remote-host-model branch from 314c455 to 54f905c Compare April 6, 2026 20:51
@juliusmarminge juliusmarminge changed the base branch from main to t3code/pr-1708/web/atomic-store-refactor April 6, 2026 20:52
- Key web stores and routes by environment-aware thread refs
- Update draft, terminal, and branch handling for scoped threads
- Add tests for the new scoped selectors and routing
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.

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

Autofix Details

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

  • ✅ Fixed: Debug tool react-scan left in production HTML
    • Removed the react-scan script tag from apps/web/index.html as it is a development-only profiling tool that should not be in production.
  • ✅ Fixed: Draft thread errors silently swallowed by incorrect guard
    • Changed the isCurrentServerThread guard to check serverThread !== undefined instead of activeThread !== undefined, so draft thread errors correctly fall through to setLocalDraftErrorsByThreadId.

Create PR

Or push these changes by commenting:

@cursor push d45f9141fb
Preview (d45f9141fb)
diff --git a/apps/web/index.html b/apps/web/index.html
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -12,7 +12,6 @@
       rel="stylesheet"
     />
     <title>T3 Code (Alpha)</title>
-    <script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>
   </head>
   <body>
     <div id="root"></div>

diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -1695,10 +1695,10 @@
       if (!targetThreadId) return;
       const nextError = sanitizeThreadErrorMessage(error);
       const isCurrentServerThread =
-        activeThread !== undefined &&
+        serverThread !== undefined &&
         targetThreadId === routeThreadRef.threadId &&
-        activeThread.environmentId === routeThreadRef.environmentId &&
-        activeThread.id === routeThreadRef.threadId;
+        serverThread.environmentId === routeThreadRef.environmentId &&
+        serverThread.id === routeThreadRef.threadId;
       if (isCurrentServerThread) {
         setStoreThreadError(targetThreadId, nextError);
         return;
@@ -1713,7 +1713,7 @@
         };
       });
     },
-    [activeThread, routeThreadRef, setStoreThreadError],
+    [serverThread, routeThreadRef, setStoreThreadError],
   );
 
   const focusComposer = useCallback(() => {

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

rel="stylesheet"
/>
<title>T3 Code (Alpha)</title>
<script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>
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.

Debug tool react-scan left in production HTML

High Severity

The react-scan profiling/debugging script is loaded from an external CDN (//unpkg.com/react-scan/dist/auto.global.js) in the production index.html. This development-only tool adds a visual overlay highlighting re-renders and will impact page load time, runtime performance, and user experience in production. It also introduces an external dependency on unpkg.com availability.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 010f452. Configure here.

rel="stylesheet"
/>
<title>T3 Code (Alpha)</title>
<script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>
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.

🔴 Critical web/index.html:15

The react-scan debugging script is unconditionally loaded from //unpkg.com in index.html, so it executes in production builds without Subresource Integrity verification. If unpkg.com or the package is compromised, arbitrary JavaScript runs in users' browsers, and the performance overhead of component monitoring ships to all users.

-    <script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/index.html around line 15:

The `react-scan` debugging script is unconditionally loaded from `//unpkg.com` in `index.html`, so it executes in production builds without Subresource Integrity verification. If unpkg.com or the package is compromised, arbitrary JavaScript runs in users' browsers, and the performance overhead of component monitoring ships to all users.

- Route composer drafts through `/draft/:threadId`
- Persist logical project draft mappings and update store lookups
- Adjust chat view, navigation, and tests for the new draft flow
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 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

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

  • ✅ Fixed: Tests use raw threadId instead of scoped key
    • Replaced all five draftsByThreadKey[newThreadId] / draftsByThreadKey[threadId] lookups with draftsByThreadKey[threadKeyFor(...)] to use the correct scoped key format expected by the store.

Create PR

Or push these changes by commenting:

@cursor push 690b18e97e
Preview (690b18e97e)
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -2560,7 +2560,9 @@
       );
       const newThreadId = threadIdFromPath(newThreadPath);
 
-      expect(useComposerDraftStore.getState().draftsByThreadKey[newThreadId]).toMatchObject({
+      expect(
+        useComposerDraftStore.getState().draftsByThreadKey[threadKeyFor(newThreadId)],
+      ).toMatchObject({
         modelSelectionByProvider: {
           codex: {
             provider: "codex",
@@ -2613,7 +2615,9 @@
       );
       const newThreadId = threadIdFromPath(newThreadPath);
 
-      expect(useComposerDraftStore.getState().draftsByThreadKey[newThreadId]).toMatchObject({
+      expect(
+        useComposerDraftStore.getState().draftsByThreadKey[threadKeyFor(newThreadId)],
+      ).toMatchObject({
         modelSelectionByProvider: {
           claudeAgent: {
             provider: "claudeAgent",
@@ -2653,7 +2657,9 @@
       );
       const newThreadId = threadIdFromPath(newThreadPath);
 
-      expect(useComposerDraftStore.getState().draftsByThreadKey[newThreadId]).toBe(undefined);
+      expect(useComposerDraftStore.getState().draftsByThreadKey[threadKeyFor(newThreadId)]).toBe(
+        undefined,
+      );
     } finally {
       await mounted.cleanup();
     }
@@ -2695,7 +2701,9 @@
       );
       const threadId = threadIdFromPath(threadPath);
 
-      expect(useComposerDraftStore.getState().draftsByThreadKey[threadId]).toMatchObject({
+      expect(
+        useComposerDraftStore.getState().draftsByThreadKey[threadKeyFor(threadId)],
+      ).toMatchObject({
         modelSelectionByProvider: {
           codex: {
             provider: "codex",
@@ -2724,7 +2732,9 @@
         (path) => path === threadPath,
         "New-thread should reuse the existing project draft thread.",
       );
-      expect(useComposerDraftStore.getState().draftsByThreadKey[threadId]).toMatchObject({
+      expect(
+        useComposerDraftStore.getState().draftsByThreadKey[threadKeyFor(threadId)],
+      ).toMatchObject({
         modelSelectionByProvider: {
           codex: {
             provider: "codex",

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

Reviewed by Cursor Bugbot for commit 24ac444. Configure here.

Comment on lines +2585 to +2595
const draftThreadEntries = Object.entries(store.draftThreadsByThreadKey).flatMap(
([threadKey]) => {
const threadRef = composerThreadRefFromKey(threadKey);
return threadRef?.threadId === threadId ? [threadRef] : [];
},
);
if (draftThreadEntries.length !== 1) {
return;
}
clearPromotedDraftThreadByRef(draftThreadEntries[0]!);
}
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 src/composerDraftStore.ts:2585

When draftThreadEntries.length > 1 (multiple draft entries reference the same threadId under different keys), the function silently returns without clearing any of them. Since the function's purpose is to "clear a draft thread once the server has materialized the same thread id," having multiple draft entries for a promoted server thread should still result in cleanup, not silent abandonment. This could leave orphaned draft data.

+  const draftThreadEntries = Object.entries(store.draftThreadsByThreadKey).flatMap(
+    ([threadKey]) => {
+      const threadRef = composerThreadRefFromKey(threadKey);
+      return threadRef?.threadId === threadId ? [threadRef] : [];
+    },
+  );
+  for (const draftThreadRef of draftThreadEntries) {
+    clearPromotedDraftThreadByRef(draftThreadRef);
+  }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/composerDraftStore.ts around lines 2585-2595:

When `draftThreadEntries.length > 1` (multiple draft entries reference the same `threadId` under different keys), the function silently returns without clearing any of them. Since the function's purpose is to "clear a draft thread once the server has materialized the same thread id," having multiple draft entries for a promoted server thread should still result in cleanup, not silent abandonment. This could leave orphaned draft data.

Evidence trail:
apps/web/src/composerDraftStore.ts lines 2579-2596 (REVIEWED_COMMIT): The `clearPromotedDraftThread` function uses `if (draftThreadEntries.length !== 1) { return; }` which causes silent return without cleanup when multiple draft entries exist for the same threadId. The docstring at lines 2570-2576 states the purpose is to "Clear a draft thread once the server has materialized the same thread id."

- Route UI actions through environment-specific native APIs
- Include environment IDs in git/react-query keys and callers
- Update affected tests and timeline components
- Update web code and tests to use `localApi` and `environmentApi`
- Rename related contracts and RPC helpers
- Move shared API wiring out of wsApi into localApi and environmentApi
- Tighten environment client lookup to avoid cross-environment fallback
- Update tests for renamed helpers and lookup behavior
- Track promoted drafts until the server thread starts
- Redirect stale draft routes to the canonical server route
- Update orchestration effects and draft-store persistence
- derive test server paths through Effect
- keep the test config aligned with ServerConfigShape
- Re-expand bootstrap projects with scoped project keys
- Keep thread errors tied to the current server thread
- Add terminal state migration and thread-ref regression tests
- mark every matching draft thread when thread ids collide across environments
- add regression coverage for shared thread ids and update lint directive syntax
- Correct oxlint and ESLint disable comment markers
- Preserve existing behavior while restoring lint compatibility
if (composerImages.length === 0) {
clearComposerDraftPersistedAttachments(threadId);
clearComposerDraftPersistedAttachments(routeThreadRef);
return;
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.

🟠 High components/ChatView.tsx:2544

In getPersistedAttachmentsForThread at line 2547, the code uses scopedThreadKey(routeThreadRef) to index into draftsByThreadKey, but for draft routes (routeKind === 'draft'), composer draft data is stored under the legacy plain threadId key. This causes the lookup to always return undefined for draft threads, so persisted attachments are never reused and fallbackPersistedAttachments is empty in the catch handler. When image serialization fails, syncComposerDraftPersistedAttachments(routeThreadRef, []) is called with an empty array, incorrectly clearing previously-persisted attachments. Consider using resolveComposerThreadRef to resolve the correct key before indexing.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/ChatView.tsx around line 2544:

In `getPersistedAttachmentsForThread` at line 2547, the code uses `scopedThreadKey(routeThreadRef)` to index into `draftsByThreadKey`, but for draft routes (`routeKind === 'draft'`), composer draft data is stored under the legacy plain `threadId` key. This causes the lookup to always return `undefined` for draft threads, so persisted attachments are never reused and `fallbackPersistedAttachments` is empty in the catch handler. When image serialization fails, `syncComposerDraftPersistedAttachments(routeThreadRef, [])` is called with an empty array, incorrectly clearing previously-persisted attachments. Consider using `resolveComposerThreadRef` to resolve the correct key before indexing.

Evidence trail:
- apps/web/src/components/ChatView.tsx lines 637-639: `routeThreadRef = scopeThreadRef(environmentId, threadId)` always uses scoped format
- apps/web/src/components/ChatView.tsx line 665: Correct pattern `useComposerThreadDraft(routeKind === 'server' ? routeThreadRef : threadId)` - draft routes pass plain `threadId`
- apps/web/src/components/ChatView.tsx line 2547: Bug - `draftsByThreadKey[scopedThreadKey(routeThreadRef)]` always uses scoped key regardless of routeKind
- apps/web/src/composerDraftStore.ts lines 818-823: `composerThreadKey` returns just `threadId` for legacy environment
- apps/web/src/composerDraftStore.ts lines 840-866: `resolveComposerThreadRef` converts string threadId to legacy ref
- apps/web/src/composerDraftStore.ts line 829: `LEGACY_COMPOSER_ENVIRONMENT_ID = '__legacy__'`
- apps/web/src/components/ChatView.browser.tsx line 485: Test code confirms dual lookup pattern `draftsByThreadKey[threadKeyFor(threadId)] ?? draftsByThreadKey[threadId]`

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.

1 participant