History

Prepare datamodel for multi-environment (#1765)

Julius Marminge committed 7 months ago

Commit b96308f1 parent 72b7f90121 files · +10206 −4969Browse files

Unified · Split

121 files
  1. apps/desktop/src/main.ts +9 −0
  2. apps/desktop/src/preload.ts +8 −0
  3. apps/server/integration/OrchestrationEngineHarness.integration.ts +2 −0
  4. apps/server/src/config.ts +2 −0
  5. apps/server/src/environment/Layers/ServerEnvironment.test.ts +124 −0
  6. apps/server/src/environment/Layers/ServerEnvironment.ts +94 −0
  7. apps/server/src/environment/Services/ServerEnvironment.ts +13 −0
  8. apps/server/src/git/Layers/GitManager.test.ts +3 −3
  9. apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +2 −0
  10. apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +5 −0
  11. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +2 −0
  12. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +6 −1
  13. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +278 −256
  14. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +2 −0
  15. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +2 −0
  16. apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +193 −0
  17. apps/server/src/project/Layers/RepositoryIdentityResolver.ts +147 −0
  18. apps/server/src/project/Services/RepositoryIdentityResolver.ts +12 −0
  19. apps/server/src/server.test.ts +245 −1
  20. apps/server/src/server.ts +4 −0
  21. apps/server/src/serverLifecycleEvents.test.ts +10 −0
  22. apps/server/src/serverRuntimeStartup.ts +12 −2
  23. apps/server/src/ws.ts +55 −1
  24. apps/web/index.html +1 −0
  25. apps/web/package.json +1 −0
  26. apps/web/src/components/BranchToolbar.tsx +36 −14
  27. apps/web/src/components/BranchToolbarBranchSelector.tsx +13 −10
  28. apps/web/src/components/ChatMarkdown.tsx +2 −2
  29. apps/web/src/components/ChatView.browser.tsx +330 −229
  30. apps/web/src/components/ChatView.logic.test.ts +57 −7
  31. apps/web/src/components/ChatView.logic.ts +36 −9
  32. apps/web/src/components/ChatView.tsx +522 −254
  33. apps/web/src/components/DiffPanel.tsx +26 −15
  34. apps/web/src/components/GitActionsControl.browser.tsx +73 −47
  35. apps/web/src/components/GitActionsControl.tsx +42 −20
  36. apps/web/src/components/KeybindingsToast.browser.tsx +26 −23
  37. apps/web/src/components/PlanSidebar.tsx +6 −3
  38. apps/web/src/components/PullRequestThreadDialog.tsx +7 −3
  39. apps/web/src/components/Sidebar.logic.test.ts +5 −1
  40. apps/web/src/components/Sidebar.tsx +2083 −1402
  41. apps/web/src/components/ThreadTerminalDrawer.browser.tsx +247 −0
  42. apps/web/src/components/ThreadTerminalDrawer.tsx +22 −14
  43. apps/web/src/components/chat/ChatHeader.tsx +10 −1
  44. apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +35 −27
  45. apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +20 −18
  46. apps/web/src/components/chat/MessagesTimeline.test.tsx +4 −0
  47. apps/web/src/components/chat/MessagesTimeline.tsx +4 −1
  48. apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +23 −6
  49. apps/web/src/components/chat/OpenInPicker.tsx +3 −3
  50. apps/web/src/components/chat/ProposedPlanCard.tsx +5 −2
  51. apps/web/src/components/chat/TraitsPicker.browser.tsx +30 −24
  52. apps/web/src/components/chat/TraitsPicker.tsx +12 −5
  53. apps/web/src/components/chat/composerProviderRegistry.test.tsx +33 −1
  54. apps/web/src/components/chat/composerProviderRegistry.tsx +93 −53
  55. apps/web/src/components/settings/SettingsPanels.browser.tsx +18 −6
  56. apps/web/src/components/settings/SettingsPanels.tsx +36 −35
  57. apps/web/src/composerDraftStore.test.ts +302 −188
  58. apps/web/src/composerDraftStore.ts +1572 −948
  59. apps/web/src/editorPreferences.ts +2 −2
  60. apps/web/src/environmentApi.ts +67 −0
  61. apps/web/src/environmentBootstrap.ts +65 −0
  62. apps/web/src/hooks/useHandleNewThread.ts +81 −42
  63. apps/web/src/hooks/useSettings.ts +4 −6
  64. apps/web/src/hooks/useThreadActions.ts +128 −64
  65. apps/web/src/lib/gitReactQuery.test.ts +30 −13
  66. apps/web/src/lib/gitReactQuery.ts +105 −53
  67. apps/web/src/lib/gitStatusState.test.ts +47 −9
  68. apps/web/src/lib/gitStatusState.ts +78 −76
  69. apps/web/src/lib/projectReactQuery.ts +17 −8
  70. apps/web/src/lib/providerReactQuery.test.ts +11 −4
  71. apps/web/src/lib/providerReactQuery.ts +11 −4
  72. apps/web/src/lib/terminalStateCleanup.test.ts +20 −13
  73. apps/web/src/lib/terminalStateCleanup.ts +9 −11
  74. apps/web/src/lib/utils.test.ts +41 −1
  75. apps/web/src/lib/utils.ts +7 −14
  76. apps/web/src/localApi.test.ts +52 −25
  77. apps/web/src/localApi.ts +94 −0
  78. apps/web/src/logicalProject.ts +19 −0
  79. apps/web/src/nativeApi.ts +0 −31
  80. apps/web/src/orchestrationEventEffects.test.ts +3 −3
  81. apps/web/src/orchestrationEventEffects.ts +4 −4
  82. apps/web/src/routeTree.gen.ts +39 −17
  83. apps/web/src/routes/__root.tsx +319 −166
  84. apps/web/src/routes/_chat.$environmentId.$threadId.tsx +61 −24
  85. apps/web/src/routes/_chat.draft.$draftId.tsx +86 −0
  86. apps/web/src/routes/_chat.tsx +17 −11
  87. apps/web/src/rpc/serverState.test.ts +18 −0
  88. apps/web/src/store.test.ts +166 −105
  89. apps/web/src/store.ts +336 −92
  90. apps/web/src/storeSelectors.ts +68 −27
  91. apps/web/src/terminalStateStore.test.ts +130 −75
  92. apps/web/src/terminalStateStore.ts +165 −127
  93. apps/web/src/threadRoutes.test.ts +67 −0
  94. apps/web/src/threadRoutes.ts +59 −0
  95. apps/web/src/threadSelectionStore.test.ts +53 −53
  96. apps/web/src/threadSelectionStore.ts +51 −53
  97. apps/web/src/types.ts +7 −0
  98. apps/web/src/uiStateStore.test.ts +11 −11
  99. apps/web/src/uiStateStore.ts +30 −35
  100. apps/web/src/vite-env.d.ts +2 −2
  101. apps/web/src/worktreeCleanup.test.ts +4 −1
  102. apps/web/src/wsNativeApi.ts +0 −117
  103. apps/web/src/wsRpcClient.test.ts +38 −2
  104. apps/web/src/wsRpcClient.ts +153 −12
  105. apps/web/src/wsTransport.test.ts +21 −0
  106. bun.lock +15 −0
  107. packages/client-runtime/package.json +25 −0
  108. packages/client-runtime/src/index.ts +2 −0
  109. packages/client-runtime/src/knownEnvironment.test.ts +63 −0
  110. packages/client-runtime/src/knownEnvironment.ts +39 −0
  111. packages/client-runtime/src/scoped.ts +64 −0
  112. packages/client-runtime/tsconfig.json +4 −0
  113. packages/contracts/src/baseSchemas.ts +2 −0
  114. packages/contracts/src/environment.ts +77 −0
  115. packages/contracts/src/index.ts +1 −0
  116. packages/contracts/src/ipc.ts +46 −20
  117. packages/contracts/src/orchestration.ts +4 −0
  118. packages/contracts/src/server.ts +4 −0
  119. packages/shared/src/git.test.ts +48 −1
  120. packages/shared/src/git.ts +50 −0
  121. scripts/release-smoke.ts +1 −0
apps/desktop/src/main.ts+9−0
@@ -60,6 +60,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; · 59 unmodified lines
6060 const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
6161 const UPDATE_CHECK_CHANNEL = "desktop:update-check";
6262 const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
63+const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
6364 const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
6465 const STATE_DIR = Path.join(BASE_DIR, "userdata");
6566 const DESKTOP_SCHEME = "t3";
@@ -1172,6 +1173,14 @@ function registerIpcHandlers(): void { · 1106 unmodified lines
11721173 event.returnValue = backendWsUrl;
11731174 });
11741175
1176+ ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
1177+ ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
1178+ event.returnValue = {
1179+ label: "Local environment",
1180+ wsUrl: backendWsUrl || null,
1181+ } as const;
1182+ });
1183+
11751184 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
11761185 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
11771186 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
apps/desktop/src/preload.ts+8−0
@@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; · 12 unmodified lines
1313 const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1414 const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
1515 const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
16+const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
1617
1718 contextBridge.exposeInMainWorld("desktopBridge", {
1819 getWsUrl: () => {
1920 const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
2021 return typeof result === "string" ? result : null;
2122 },
23+ getLocalEnvironmentBootstrap: () => {
24+ const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
25+ if (typeof result !== "object" || result === null) {
26+ return null;
27+ }
28+ return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>;
29+ },
2230 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
2331 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
2432 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
apps/server/integration/OrchestrationEngineHarness.integration.ts+2−0
@@ -45,6 +45,7 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; · 44 unmodified lines
4545 import { ProviderService } from "../src/provider/Services/ProviderService.ts";
4646 import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
4747 import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
48+import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts";
4849 import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
4950 import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
5051 import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
@@ -338,6 +339,7 @@ export const makeOrchestrationIntegrationHarness = ( · 287 unmodified lines
338339 Layer.provideMerge(runtimeServicesLayer),
339340 Layer.provideMerge(orchestrationReactorLayer),
340341 Layer.provide(persistenceLayer),
342+ Layer.provideMerge(RepositoryIdentityResolverLive),
341343 Layer.provideMerge(ServerSettingsService.layerTest()),
342344 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
343345 Layer.provideMerge(NodeServices.layer),
apps/server/src/config.ts+2−0
@@ -30,6 +30,7 @@ export interface ServerDerivedPaths { · 29 unmodified lines
3030 readonly providerEventLogPath: string;
3131 readonly terminalLogsDir: string;
3232 readonly anonymousIdPath: string;
33+ readonly environmentIdPath: string;
3334 }
3435
3536 /**
@@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* ( · 47 unmodified lines
8384 providerEventLogPath: join(providerLogsDir, "events.log"),
8485 terminalLogsDir: join(logsDir, "terminals"),
8586 anonymousIdPath: join(stateDir, "anonymous-id"),
87+ environmentIdPath: join(stateDir, "environment-id"),
8688 };
8789 });
8890
apps/server/src/environment/Layers/ServerEnvironment.test.ts+124−0
@@ -0,0 +1,124 @@
1+import * as nodePath from "node:path";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import { expect, it } from "@effect/vitest";
4+import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect";
5+
6+import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts";
7+import { ServerEnvironment } from "../Services/ServerEnvironment.ts";
8+import { ServerEnvironmentLive } from "./ServerEnvironment.ts";
9+
10+const makeServerEnvironmentLayer = (baseDir: string) =>
11+ ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
12+
13+const makeServerConfig = Effect.fn(function* (baseDir: string) {
14+ const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
15+
16+ return {
17+ ...derivedPaths,
18+ logLevel: "Error",
19+ traceMinLevel: "Info",
20+ traceTimingEnabled: true,
21+ traceBatchWindowMs: 200,
22+ traceMaxBytes: 10 * 1024 * 1024,
23+ traceMaxFiles: 10,
24+ otlpTracesUrl: undefined,
25+ otlpMetricsUrl: undefined,
26+ otlpExportIntervalMs: 10_000,
27+ otlpServiceName: "t3-server",
28+ cwd: process.cwd(),
29+ baseDir,
30+ mode: "web",
31+ autoBootstrapProjectFromCwd: false,
32+ logWebSocketEvents: false,
33+ port: 0,
34+ host: undefined,
35+ authToken: undefined,
36+ staticDir: undefined,
37+ devUrl: undefined,
38+ noBrowser: false,
39+ } satisfies ServerConfigShape;
40+});
41+
42+it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
43+ it.effect("persists the environment id across service restarts", () =>
44+ Effect.gen(function* () {
45+ const fileSystem = yield* FileSystem.FileSystem;
46+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
47+ prefix: "t3-server-environment-test-",
48+ });
49+
50+ const first = yield* Effect.gen(function* () {
51+ const serverEnvironment = yield* ServerEnvironment;
52+ return yield* serverEnvironment.getDescriptor;
53+ }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));
54+ const second = yield* Effect.gen(function* () {
55+ const serverEnvironment = yield* ServerEnvironment;
56+ return yield* serverEnvironment.getDescriptor;
57+ }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));
58+
59+ expect(first.environmentId).toBe(second.environmentId);
60+ expect(second.capabilities.repositoryIdentity).toBe(true);
61+ }),
62+ );
63+
64+ it.effect("fails instead of overwriting a persisted id when reading the file errors", () =>
65+ Effect.gen(function* () {
66+ const fileSystem = yield* FileSystem.FileSystem;
67+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
68+ prefix: "t3-server-environment-read-error-test-",
69+ });
70+ const serverConfig = yield* makeServerConfig(baseDir);
71+ const environmentIdPath = serverConfig.environmentIdPath;
72+ yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true });
73+ yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n");
74+ const writeAttempts: string[] = [];
75+ const failingFileSystemLayer = FileSystem.layerNoop({
76+ exists: (path) => Effect.succeed(path === environmentIdPath),
77+ readFileString: (path) =>
78+ path === environmentIdPath
79+ ? Effect.fail(
80+ PlatformError.systemError({
81+ _tag: "PermissionDenied",
82+ module: "FileSystem",
83+ method: "readFileString",
84+ description: "permission denied",
85+ pathOrDescriptor: path,
86+ }),
87+ )
88+ : Effect.fail(
89+ PlatformError.systemError({
90+ _tag: "NotFound",
91+ module: "FileSystem",
92+ method: "readFileString",
93+ description: "not found",
94+ pathOrDescriptor: path,
95+ }),
96+ ),
97+ writeFileString: (path) => {
98+ writeAttempts.push(path);
99+ return Effect.void;
100+ },
101+ });
102+
103+ const exit = yield* Effect.gen(function* () {
104+ const serverEnvironment = yield* ServerEnvironment;
105+ return yield* serverEnvironment.getDescriptor;
106+ }).pipe(
107+ Effect.provide(
108+ ServerEnvironmentLive.pipe(
109+ Layer.provide(
110+ Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer),
111+ ),
112+ ),
113+ ),
114+ Effect.exit,
115+ );
116+
117+ expect(Exit.isFailure(exit)).toBe(true);
118+ expect(writeAttempts).toEqual([]);
119+ expect(yield* fileSystem.readFileString(environmentIdPath)).toBe(
120+ "persisted-environment-id\n",
121+ );
122+ }),
123+ );
124+});
apps/server/src/environment/Layers/ServerEnvironment.ts+94−0
@@ -0,0 +1,94 @@
1+import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2+import { Effect, FileSystem, Layer, Path, Random } from "effect";
3+
4+import { ServerConfig } from "../../config.ts";
5+import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";
6+import { version } from "../../../package.json" with { type: "json" };
7+
8+function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] {
9+ switch (process.platform) {
10+ case "darwin":
11+ return "darwin";
12+ case "linux":
13+ return "linux";
14+ case "win32":
15+ return "windows";
16+ default:
17+ return "unknown";
18+ }
19+}
20+
21+function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] {
22+ switch (process.arch) {
23+ case "arm64":
24+ return "arm64";
25+ case "x64":
26+ return "x64";
27+ default:
28+ return "other";
29+ }
30+}
31+
32+export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () {
33+ const fileSystem = yield* FileSystem.FileSystem;
34+ const path = yield* Path.Path;
35+ const serverConfig = yield* ServerConfig;
36+
37+ const readPersistedEnvironmentId = Effect.gen(function* () {
38+ const exists = yield* fileSystem
39+ .exists(serverConfig.environmentIdPath)
40+ .pipe(Effect.orElseSucceed(() => false));
41+ if (!exists) {
42+ return null;
43+ }
44+
45+ const raw = yield* fileSystem
46+ .readFileString(serverConfig.environmentIdPath)
47+ .pipe(Effect.map((value) => value.trim()));
48+
49+ return raw.length > 0 ? raw : null;
50+ });
51+
52+ const persistEnvironmentId = (value: string) =>
53+ fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`);
54+
55+ const environmentIdRaw = yield* Effect.gen(function* () {
56+ const persisted = yield* readPersistedEnvironmentId;
57+ if (persisted) {
58+ return persisted;
59+ }
60+
61+ const generated = yield* Random.nextUUIDv4;
62+ yield* persistEnvironmentId(generated);
63+ return generated;
64+ });
65+
66+ const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw);
67+ const cwdBaseName = path.basename(serverConfig.cwd).trim();
68+ const label =
69+ serverConfig.mode === "desktop"
70+ ? "Local environment"
71+ : cwdBaseName.length > 0
72+ ? cwdBaseName
73+ : "T3 environment";
74+
75+ const descriptor: ExecutionEnvironmentDescriptor = {
76+ environmentId,
77+ label,
78+ platform: {
79+ os: platformOs(),
80+ arch: platformArch(),
81+ },
82+ serverVersion: version,
83+ capabilities: {
84+ repositoryIdentity: true,
85+ },
86+ };
87+
88+ return {
89+ getEnvironmentId: Effect.succeed(environmentId),
90+ getDescriptor: Effect.succeed(descriptor),
91+ } satisfies ServerEnvironmentShape;
92+});
93+
94+export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment());
apps/server/src/environment/Services/ServerEnvironment.ts+13−0
@@ -0,0 +1,13 @@
1+import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2+import { ServiceMap } from "effect";
3+import type { Effect } from "effect";
4+
5+export interface ServerEnvironmentShape {
6+ readonly getEnvironmentId: Effect.Effect<EnvironmentId>;
7+ readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>;
8+}
9+
10+export class ServerEnvironment extends ServiceMap.Service<
11+ ServerEnvironment,
12+ ServerEnvironmentShape
13+>()("t3/environment/Services/ServerEnvironment") {}
apps/server/src/git/Layers/GitManager.test.ts+3−3
@@ -854,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 853 unmodified lines
854854 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
855855 );
856856 }),
857− 12_000,
857+ 20_000,
858858 );
859859
860860 it.effect(
@@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 101 unmodified lines
962962 ),
963963 ).toBe(false);
964964 }),
965− 12_000,
965+ 20_000,
966966 );
967967
968968 it.effect("status returns merged PR state when latest PR was merged", () =>
@@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 716 unmodified lines
16851685 false,
16861686 );
16871687 }),
1688− 12_000,
1688+ 20_000,
16891689 );
16901690
16911691 it.effect(
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2−0
@@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; · 19 unmodified lines
2020 import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
2121 import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
2222 import { GitCoreLive } from "../../git/Layers/GitCore.ts";
23+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2324 import { CheckpointReactorLive } from "./CheckpointReactor.ts";
2425 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2526 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
@@ -256,6 +257,7 @@ describe("CheckpointReactor", () => { · 230 unmodified lines
256257 Layer.provide(OrchestrationProjectionPipelineLive),
257258 Layer.provide(OrchestrationEventStoreLive),
258259 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
260+ Layer.provide(RepositoryIdentityResolverLive),
259261 Layer.provide(SqlitePersistenceMemory),
260262 );
261263
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 OrchestrationEventStore,
2020 type OrchestrationEventStoreShape,
2121 } from "../../persistence/Services/OrchestrationEventStore.ts";
22+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2223 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2324 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
2425 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -45,6 +46,7 @@ async function createOrchestrationSystem() { · 20 unmodified lines
4546 Layer.provide(OrchestrationProjectionPipelineLive),
4647 Layer.provide(OrchestrationEventStoreLive),
4748 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
49+ Layer.provide(RepositoryIdentityResolverLive),
4850 Layer.provide(SqlitePersistenceMemory),
4951 Layer.provideMerge(ServerConfigLayer),
5052 Layer.provideMerge(NodeServices.layer),
@@ -623,6 +625,7 @@ describe("OrchestrationEngine", () => { · 572 unmodified lines
623625 Layer.provide(OrchestrationProjectionPipelineLive),
624626 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
625627 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
628+ Layer.provide(RepositoryIdentityResolverLive),
626629 Layer.provide(SqlitePersistenceMemory),
627630 Layer.provideMerge(ServerConfigLayer),
628631 Layer.provideMerge(NodeServices.layer),
@@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => { · 90 unmodified lines
719722 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
720723 Layer.provide(OrchestrationEventStoreLive),
721724 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
725+ Layer.provide(RepositoryIdentityResolverLive),
722726 Layer.provide(SqlitePersistenceMemory),
723727 ),
724728 );
@@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => { · 136 unmodified lines
861865 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
862866 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
863867 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
868+ Layer.provide(RepositoryIdentityResolverLive),
864869 Layer.provide(SqlitePersistenceMemory),
865870 ),
866871 );
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2−0
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
2020 SqlitePersistenceMemory,
2121 } from "../../persistence/Layers/Sqlite.ts";
2222 import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
23+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2324 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2425 import {
2526 ORCHESTRATION_PROJECTOR_NAMES,
@@ -1846,6 +1847,7 @@ const engineLayer = it.layer( · 1820 unmodified lines
18461847 Layer.provide(OrchestrationProjectionPipelineLive),
18471848 Layer.provide(OrchestrationEventStoreLive),
18481849 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
1850+ Layer.provide(RepositoryIdentityResolverLive),
18491851 Layer.provideMerge(SqlitePersistenceMemory),
18501852 Layer.provideMerge(
18511853 ServerConfig.layerTest(process.cwd(), {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+6−1
@@ -4,6 +4,7 @@ import { Effect, Layer } from "effect"; · 3 unmodified lines
44 import * as SqlClient from "effect/unstable/sql/SqlClient";
55
66 import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
7+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
78 import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
89 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
910 import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
@@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value); · 5 unmodified lines
1516 const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
1617
1718 const projectionSnapshotLayer = it.layer(
18− OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
19+ OrchestrationProjectionSnapshotQueryLive.pipe(
20+ Layer.provideMerge(RepositoryIdentityResolverLive),
21+ Layer.provideMerge(SqlitePersistenceMemory),
22+ ),
1923 );
2024
2125 projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
@@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 212 unmodified lines
234238 id: asProjectId("project-1"),
235239 title: "Project 1",
236240 workspaceRoot: "/tmp/project-1",
241+ repositoryIdentity: null,
237242 defaultModelSelection: {
238243 provider: "codex",
239244 model: "gpt-5-codex",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+278−256
@@ -38,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh · 37 unmodified lines
3838 import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
3939 import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
4040 import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
41+import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
4142 import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
4243 import {
4344 ProjectionSnapshotQuery,
@@ -163,6 +164,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st · 119 unmodified lines
163164
164165 const makeProjectionSnapshotQuery = Effect.gen(function* () {
165166 const sql = yield* SqlClient.SqlClient;
167+ const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
168+ const repositoryIdentityResolutionConcurrency = 4;
166169
167170 const listProjectRows = SqlSchema.findAll({
168171 Request: Schema.Void,
@@ -436,269 +439,283 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 267 unmodified lines
436439 const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () =>
437440 sql
438441 .withTransaction(
439− Effect.gen(function* () {
440− const [
441− projectRows,
442− threadRows,
443− messageRows,
444− proposedPlanRows,
445− activityRows,
446− sessionRows,
447− checkpointRows,
448− latestTurnRows,
449− stateRows,
450− ] = yield* Effect.all([
451− listProjectRows(undefined).pipe(
452− Effect.mapError(
453− toPersistenceSqlOrDecodeError(
454− "ProjectionSnapshotQuery.getSnapshot:listProjects:query",
455− "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows",
456− ),
457− ),
458− ),
459− listThreadRows(undefined).pipe(
460− Effect.mapError(
461− toPersistenceSqlOrDecodeError(
462− "ProjectionSnapshotQuery.getSnapshot:listThreads:query",
463− "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows",
464− ),
442+ Effect.all([
443+ listProjectRows(undefined).pipe(
444+ Effect.mapError(
445+ toPersistenceSqlOrDecodeError(
446+ "ProjectionSnapshotQuery.getSnapshot:listProjects:query",
447+ "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows",
465448 ),
466449 ),
467− listThreadMessageRows(undefined).pipe(
468− Effect.mapError(
469− toPersistenceSqlOrDecodeError(
470− "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query",
471− "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows",
472− ),
450+ ),
451+ listThreadRows(undefined).pipe(
452+ Effect.mapError(
453+ toPersistenceSqlOrDecodeError(
454+ "ProjectionSnapshotQuery.getSnapshot:listThreads:query",
455+ "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows",
473456 ),
474457 ),
475− listThreadProposedPlanRows(undefined).pipe(
476− Effect.mapError(
477− toPersistenceSqlOrDecodeError(
478− "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query",
479− "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows",
480− ),
458+ ),
459+ listThreadMessageRows(undefined).pipe(
460+ Effect.mapError(
461+ toPersistenceSqlOrDecodeError(
462+ "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query",
463+ "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows",
481464 ),
482465 ),
483− listThreadActivityRows(undefined).pipe(
484− Effect.mapError(
485− toPersistenceSqlOrDecodeError(
486− "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query",
487− "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows",
488− ),
466+ ),
467+ listThreadProposedPlanRows(undefined).pipe(
468+ Effect.mapError(
469+ toPersistenceSqlOrDecodeError(
470+ "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query",
471+ "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows",
489472 ),
490473 ),
491− listThreadSessionRows(undefined).pipe(
492− Effect.mapError(
493− toPersistenceSqlOrDecodeError(
494− "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query",
495− "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows",
496− ),
474+ ),
475+ listThreadActivityRows(undefined).pipe(
476+ Effect.mapError(
477+ toPersistenceSqlOrDecodeError(
478+ "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query",
479+ "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows",
497480 ),
498481 ),
499− listCheckpointRows(undefined).pipe(
500− Effect.mapError(
501− toPersistenceSqlOrDecodeError(
502− "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query",
503− "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows",
504− ),
482+ ),
483+ listThreadSessionRows(undefined).pipe(
484+ Effect.mapError(
485+ toPersistenceSqlOrDecodeError(
486+ "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query",
487+ "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows",
505488 ),
506489 ),
507− listLatestTurnRows(undefined).pipe(
508− Effect.mapError(
509− toPersistenceSqlOrDecodeError(
510− "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query",
511− "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows",
512− ),
490+ ),
491+ listCheckpointRows(undefined).pipe(
492+ Effect.mapError(
493+ toPersistenceSqlOrDecodeError(
494+ "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query",
495+ "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows",
513496 ),
514497 ),
515− listProjectionStateRows(undefined).pipe(
516− Effect.mapError(
517− toPersistenceSqlOrDecodeError(
518− "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query",
519− "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows",
520− ),
498+ ),
499+ listLatestTurnRows(undefined).pipe(
500+ Effect.mapError(
501+ toPersistenceSqlOrDecodeError(
502+ "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query",
503+ "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows",
521504 ),
522505 ),
523− ]);
524−
525− const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
526− const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
527− const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
528− const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
529− const sessionsByThread = new Map<string, OrchestrationSession>();
530− const latestTurnByThread = new Map<string, OrchestrationLatestTurn>();
531−
532− let updatedAt: string | null = null;
533−
534− for (const row of projectRows) {
535− updatedAt = maxIso(updatedAt, row.updatedAt);
536− }
537− for (const row of threadRows) {
538− updatedAt = maxIso(updatedAt, row.updatedAt);
539− }
540− for (const row of stateRows) {
541− updatedAt = maxIso(updatedAt, row.updatedAt);
542− }
543−
544− for (const row of messageRows) {
545− updatedAt = maxIso(updatedAt, row.updatedAt);
546− const threadMessages = messagesByThread.get(row.threadId) ?? [];
547− threadMessages.push({
548− id: row.messageId,
549− role: row.role,
550− text: row.text,
551− ...(row.attachments !== null ? { attachments: row.attachments } : {}),
552− turnId: row.turnId,
553− streaming: row.isStreaming === 1,
554− createdAt: row.createdAt,
555− updatedAt: row.updatedAt,
556− });
557− messagesByThread.set(row.threadId, threadMessages);
558− }
559−
560− for (const row of proposedPlanRows) {
561− updatedAt = maxIso(updatedAt, row.updatedAt);
562− const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? [];
563− threadProposedPlans.push({
564− id: row.planId,
565− turnId: row.turnId,
566− planMarkdown: row.planMarkdown,
567− implementedAt: row.implementedAt,
568− implementationThreadId: row.implementationThreadId,
569− createdAt: row.createdAt,
570− updatedAt: row.updatedAt,
571− });
572− proposedPlansByThread.set(row.threadId, threadProposedPlans);
573− }
574−
575− for (const row of activityRows) {
576− updatedAt = maxIso(updatedAt, row.createdAt);
577− const threadActivities = activitiesByThread.get(row.threadId) ?? [];
578− threadActivities.push({
579− id: row.activityId,
580− tone: row.tone,
581− kind: row.kind,
582− summary: row.summary,
583− payload: row.payload,
584− turnId: row.turnId,
585− ...(row.sequence !== null ? { sequence: row.sequence } : {}),
586− createdAt: row.createdAt,
587− });
588− activitiesByThread.set(row.threadId, threadActivities);
589− }
590−
591− for (const row of checkpointRows) {
592− updatedAt = maxIso(updatedAt, row.completedAt);
593− const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? [];
594− threadCheckpoints.push({
595− turnId: row.turnId,
596− checkpointTurnCount: row.checkpointTurnCount,
597− checkpointRef: row.checkpointRef,
598− status: row.status,
599− files: row.files,
600− assistantMessageId: row.assistantMessageId,
601− completedAt: row.completedAt,
602− });
603− checkpointsByThread.set(row.threadId, threadCheckpoints);
604− }
605−
606− for (const row of latestTurnRows) {
607− updatedAt = maxIso(updatedAt, row.requestedAt);
608− if (row.startedAt !== null) {
609− updatedAt = maxIso(updatedAt, row.startedAt);
610− }
611− if (row.completedAt !== null) {
612− updatedAt = maxIso(updatedAt, row.completedAt);
613− }
614− if (latestTurnByThread.has(row.threadId)) {
615− continue;
616− }
617− latestTurnByThread.set(row.threadId, {
618− turnId: row.turnId,
619− state:
620− row.state === "error"
621− ? "error"
622− : row.state === "interrupted"
623− ? "interrupted"
624− : row.state === "completed"
625− ? "completed"
626− : "running",
627− requestedAt: row.requestedAt,
628− startedAt: row.startedAt,
629− completedAt: row.completedAt,
630− assistantMessageId: row.assistantMessageId,
631− ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null
632− ? {
633− sourceProposedPlan: {
634− threadId: row.sourceProposedPlanThreadId,
635− planId: row.sourceProposedPlanId,
636− },
637− }
638− : {}),
639− });
640− }
641−
642− for (const row of sessionRows) {
643− updatedAt = maxIso(updatedAt, row.updatedAt);
644− sessionsByThread.set(row.threadId, {
645− threadId: row.threadId,
646− status: row.status,
647− providerName: row.providerName,
648− runtimeMode: row.runtimeMode,
649− activeTurnId: row.activeTurnId,
650− lastError: row.lastError,
651− updatedAt: row.updatedAt,
652− });
653− }
654−
655− const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
656− id: row.projectId,
657− title: row.title,
658− workspaceRoot: row.workspaceRoot,
659− defaultModelSelection: row.defaultModelSelection,
660− scripts: row.scripts,
661− createdAt: row.createdAt,
662− updatedAt: row.updatedAt,
663− deletedAt: row.deletedAt,
664− }));
665−
666− const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
667− id: row.threadId,
668− projectId: row.projectId,
669− title: row.title,
670− modelSelection: row.modelSelection,
671− runtimeMode: row.runtimeMode,
672− interactionMode: row.interactionMode,
673− branch: row.branch,
674− worktreePath: row.worktreePath,
675− latestTurn: latestTurnByThread.get(row.threadId) ?? null,
676− createdAt: row.createdAt,
677− updatedAt: row.updatedAt,
678− archivedAt: row.archivedAt,
679− deletedAt: row.deletedAt,
680− messages: messagesByThread.get(row.threadId) ?? [],
681− proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
682− activities: activitiesByThread.get(row.threadId) ?? [],
683− checkpoints: checkpointsByThread.get(row.threadId) ?? [],
684− session: sessionsByThread.get(row.threadId) ?? null,
685− }));
686−
687− const snapshot = {
688− snapshotSequence: computeSnapshotSequence(stateRows),
689− projects,
690− threads,
691− updatedAt: updatedAt ?? new Date(0).toISOString(),
692− };
693−
694− return yield* decodeReadModel(snapshot).pipe(
506+ ),
507+ listProjectionStateRows(undefined).pipe(
695508 Effect.mapError(
696− toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"),
509+ toPersistenceSqlOrDecodeError(
510+ "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query",
511+ "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows",
512+ ),
697513 ),
698− );
699− }),
514+ ),
515+ ]),
700516 )
701517 .pipe(
518+ Effect.flatMap(
519+ ([
520+ projectRows,
521+ threadRows,
522+ messageRows,
523+ proposedPlanRows,
524+ activityRows,
525+ sessionRows,
526+ checkpointRows,
527+ latestTurnRows,
528+ stateRows,
529+ ]) =>
530+ Effect.gen(function* () {
531+ const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
532+ const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
533+ const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
534+ const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
535+ const sessionsByThread = new Map<string, OrchestrationSession>();
536+ const latestTurnByThread = new Map<string, OrchestrationLatestTurn>();
537+
538+ let updatedAt: string | null = null;
539+
540+ for (const row of projectRows) {
541+ updatedAt = maxIso(updatedAt, row.updatedAt);
542+ }
543+ for (const row of threadRows) {
544+ updatedAt = maxIso(updatedAt, row.updatedAt);
545+ }
546+ for (const row of stateRows) {
547+ updatedAt = maxIso(updatedAt, row.updatedAt);
548+ }
549+
550+ for (const row of messageRows) {
551+ updatedAt = maxIso(updatedAt, row.updatedAt);
552+ const threadMessages = messagesByThread.get(row.threadId) ?? [];
553+ threadMessages.push({
554+ id: row.messageId,
555+ role: row.role,
556+ text: row.text,
557+ ...(row.attachments !== null ? { attachments: row.attachments } : {}),
558+ turnId: row.turnId,
559+ streaming: row.isStreaming === 1,
560+ createdAt: row.createdAt,
561+ updatedAt: row.updatedAt,
562+ });
563+ messagesByThread.set(row.threadId, threadMessages);
564+ }
565+
566+ for (const row of proposedPlanRows) {
567+ updatedAt = maxIso(updatedAt, row.updatedAt);
568+ const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? [];
569+ threadProposedPlans.push({
570+ id: row.planId,
571+ turnId: row.turnId,
572+ planMarkdown: row.planMarkdown,
573+ implementedAt: row.implementedAt,
574+ implementationThreadId: row.implementationThreadId,
575+ createdAt: row.createdAt,
576+ updatedAt: row.updatedAt,
577+ });
578+ proposedPlansByThread.set(row.threadId, threadProposedPlans);
579+ }
580+
581+ for (const row of activityRows) {
582+ updatedAt = maxIso(updatedAt, row.createdAt);
583+ const threadActivities = activitiesByThread.get(row.threadId) ?? [];
584+ threadActivities.push({
585+ id: row.activityId,
586+ tone: row.tone,
587+ kind: row.kind,
588+ summary: row.summary,
589+ payload: row.payload,
590+ turnId: row.turnId,
591+ ...(row.sequence !== null ? { sequence: row.sequence } : {}),
592+ createdAt: row.createdAt,
593+ });
594+ activitiesByThread.set(row.threadId, threadActivities);
595+ }
596+
597+ for (const row of checkpointRows) {
598+ updatedAt = maxIso(updatedAt, row.completedAt);
599+ const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? [];
600+ threadCheckpoints.push({
601+ turnId: row.turnId,
602+ checkpointTurnCount: row.checkpointTurnCount,
603+ checkpointRef: row.checkpointRef,
604+ status: row.status,
605+ files: row.files,
606+ assistantMessageId: row.assistantMessageId,
607+ completedAt: row.completedAt,
608+ });
609+ checkpointsByThread.set(row.threadId, threadCheckpoints);
610+ }
611+
612+ for (const row of latestTurnRows) {
613+ updatedAt = maxIso(updatedAt, row.requestedAt);
614+ if (row.startedAt !== null) {
615+ updatedAt = maxIso(updatedAt, row.startedAt);
616+ }
617+ if (row.completedAt !== null) {
618+ updatedAt = maxIso(updatedAt, row.completedAt);
619+ }
620+ if (latestTurnByThread.has(row.threadId)) {
621+ continue;
622+ }
623+ latestTurnByThread.set(row.threadId, {
624+ turnId: row.turnId,
625+ state:
626+ row.state === "error"
627+ ? "error"
628+ : row.state === "interrupted"
629+ ? "interrupted"
630+ : row.state === "completed"
631+ ? "completed"
632+ : "running",
633+ requestedAt: row.requestedAt,
634+ startedAt: row.startedAt,
635+ completedAt: row.completedAt,
636+ assistantMessageId: row.assistantMessageId,
637+ ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null
638+ ? {
639+ sourceProposedPlan: {
640+ threadId: row.sourceProposedPlanThreadId,
641+ planId: row.sourceProposedPlanId,
642+ },
643+ }
644+ : {}),
645+ });
646+ }
647+
648+ for (const row of sessionRows) {
649+ updatedAt = maxIso(updatedAt, row.updatedAt);
650+ sessionsByThread.set(row.threadId, {
651+ threadId: row.threadId,
652+ status: row.status,
653+ providerName: row.providerName,
654+ runtimeMode: row.runtimeMode,
655+ activeTurnId: row.activeTurnId,
656+ lastError: row.lastError,
657+ updatedAt: row.updatedAt,
658+ });
659+ }
660+
661+ const repositoryIdentities = new Map(
662+ yield* Effect.forEach(
663+ projectRows,
664+ (row) =>
665+ repositoryIdentityResolver
666+ .resolve(row.workspaceRoot)
667+ .pipe(Effect.map((identity) => [row.projectId, identity] as const)),
668+ { concurrency: repositoryIdentityResolutionConcurrency },
669+ ),
670+ );
671+
672+ const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
673+ id: row.projectId,
674+ title: row.title,
675+ workspaceRoot: row.workspaceRoot,
676+ repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
677+ defaultModelSelection: row.defaultModelSelection,
678+ scripts: row.scripts,
679+ createdAt: row.createdAt,
680+ updatedAt: row.updatedAt,
681+ deletedAt: row.deletedAt,
682+ }));
683+
684+ const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
685+ id: row.threadId,
686+ projectId: row.projectId,
687+ title: row.title,
688+ modelSelection: row.modelSelection,
689+ runtimeMode: row.runtimeMode,
690+ interactionMode: row.interactionMode,
691+ branch: row.branch,
692+ worktreePath: row.worktreePath,
693+ latestTurn: latestTurnByThread.get(row.threadId) ?? null,
694+ createdAt: row.createdAt,
695+ updatedAt: row.updatedAt,
696+ archivedAt: row.archivedAt,
697+ deletedAt: row.deletedAt,
698+ messages: messagesByThread.get(row.threadId) ?? [],
699+ proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
700+ activities: activitiesByThread.get(row.threadId) ?? [],
701+ checkpoints: checkpointsByThread.get(row.threadId) ?? [],
702+ session: sessionsByThread.get(row.threadId) ?? null,
703+ }));
704+
705+ const snapshot = {
706+ snapshotSequence: computeSnapshotSequence(stateRows),
707+ projects,
708+ threads,
709+ updatedAt: updatedAt ?? new Date(0).toISOString(),
710+ };
711+
712+ return yield* decodeReadModel(snapshot).pipe(
713+ Effect.mapError(
714+ toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"),
715+ ),
716+ );
717+ }),
718+ ),
702719 Effect.mapError((error) => {
703720 if (isPersistenceError(error)) {
704721 return error;
@@ -732,19 +749,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 27 unmodified lines
732749 "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow",
733750 ),
734751 ),
735− Effect.map(
736− Option.map(
737− (row): OrchestrationProject => ({
738− id: row.projectId,
739− title: row.title,
740− workspaceRoot: row.workspaceRoot,
741− defaultModelSelection: row.defaultModelSelection,
742− scripts: row.scripts,
743− createdAt: row.createdAt,
744− updatedAt: row.updatedAt,
745− deletedAt: row.deletedAt,
746− }),
747− ),
752+ Effect.flatMap((option) =>
753+ Option.isNone(option)
754+ ? Effect.succeed(Option.none<OrchestrationProject>())
755+ : repositoryIdentityResolver.resolve(option.value.workspaceRoot).pipe(
756+ Effect.map((repositoryIdentity) =>
757+ Option.some({
758+ id: option.value.projectId,
759+ title: option.value.title,
760+ workspaceRoot: option.value.workspaceRoot,
761+ repositoryIdentity,
762+ defaultModelSelection: option.value.defaultModelSelection,
763+ scripts: option.value.scripts,
764+ createdAt: option.value.createdAt,
765+ updatedAt: option.value.updatedAt,
766+ deletedAt: option.value.deletedAt,
767+ } satisfies OrchestrationProject),
768+ ),
769+ ),
748770 ),
749771 );
750772
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+2−0
@@ -28,6 +28,7 @@ import { · 27 unmodified lines
2828 } from "../../provider/Services/ProviderService.ts";
2929 import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";
3030 import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";
31+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
3132 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
3233 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3334 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => { · 184 unmodified lines
218219 Layer.provide(OrchestrationProjectionPipelineLive),
219220 Layer.provide(OrchestrationEventStoreLive),
220221 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
222+ Layer.provide(RepositoryIdentityResolverLive),
221223 Layer.provide(SqlitePersistenceMemory),
222224 );
223225 const layer = ProviderCommandReactorLive.pipe(
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+2−0
@@ -29,6 +29,7 @@ import { · 28 unmodified lines
2929 ProviderService,
3030 type ProviderServiceShape,
3131 } from "../../provider/Services/ProviderService.ts";
32+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
3233 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
3334 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3435 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => { · 169 unmodified lines
204205 Layer.provide(OrchestrationProjectionPipelineLive),
205206 Layer.provide(OrchestrationEventStoreLive),
206207 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
208+ Layer.provide(RepositoryIdentityResolverLive),
207209 Layer.provide(SqlitePersistenceMemory),
208210 );
209211 const layer = ProviderRuntimeIngestionLive.pipe(
apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts+193−0
@@ -0,0 +1,193 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { expect, it } from "@effect/vitest";
3+import { Duration, Effect, FileSystem, Layer } from "effect";
4+import { TestClock } from "effect/testing";
5+
6+import { runProcess } from "../../processRunner.ts";
7+import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts";
8+import {
9+ makeRepositoryIdentityResolver,
10+ RepositoryIdentityResolverLive,
11+} from "./RepositoryIdentityResolver.ts";
12+
13+const git = (cwd: string, args: ReadonlyArray<string>) =>
14+ Effect.promise(() => runProcess("git", ["-C", cwd, ...args]));
15+
16+const makeRepositoryIdentityResolverTestLayer = (options: {
17+ readonly positiveCacheTtl?: Duration.Input;
18+ readonly negativeCacheTtl?: Duration.Input;
19+}) =>
20+ Layer.effect(
21+ RepositoryIdentityResolver,
22+ makeRepositoryIdentityResolver({
23+ cacheCapacity: 16,
24+ ...options,
25+ }),
26+ );
27+
28+it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
29+ it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
30+ Effect.gen(function* () {
31+ const fileSystem = yield* FileSystem.FileSystem;
32+ const cwd = yield* fileSystem.makeTempDirectoryScoped({
33+ prefix: "t3-repository-identity-test-",
34+ });
35+
36+ yield* git(cwd, ["init"]);
37+ yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
38+
39+ const resolver = yield* RepositoryIdentityResolver;
40+ const identity = yield* resolver.resolve(cwd);
41+
42+ expect(identity).not.toBeNull();
43+ expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
44+ expect(identity?.displayName).toBe("t3tools/t3code");
45+ expect(identity?.provider).toBe("github");
46+ expect(identity?.owner).toBe("t3tools");
47+ expect(identity?.name).toBe("t3code");
48+ }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
49+ );
50+
51+ it.effect("returns null for non-git folders and repos without remotes", () =>
52+ Effect.gen(function* () {
53+ const fileSystem = yield* FileSystem.FileSystem;
54+ const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({
55+ prefix: "t3-repository-identity-non-git-",
56+ });
57+ const gitDir = yield* fileSystem.makeTempDirectoryScoped({
58+ prefix: "t3-repository-identity-no-remote-",
59+ });
60+
61+ yield* git(gitDir, ["init"]);
62+
63+ const resolver = yield* RepositoryIdentityResolver;
64+ const nonGitIdentity = yield* resolver.resolve(nonGitDir);
65+ const noRemoteIdentity = yield* resolver.resolve(gitDir);
66+
67+ expect(nonGitIdentity).toBeNull();
68+ expect(noRemoteIdentity).toBeNull();
69+ }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
70+ );
71+
72+ it.effect("prefers upstream over origin when both remotes are configured", () =>
73+ Effect.gen(function* () {
74+ const fileSystem = yield* FileSystem.FileSystem;
75+ const cwd = yield* fileSystem.makeTempDirectoryScoped({
76+ prefix: "t3-repository-identity-upstream-test-",
77+ });
78+
79+ yield* git(cwd, ["init"]);
80+ yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]);
81+ yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
82+
83+ const resolver = yield* RepositoryIdentityResolver;
84+ const identity = yield* resolver.resolve(cwd);
85+
86+ expect(identity).not.toBeNull();
87+ expect(identity?.locator.remoteName).toBe("upstream");
88+ expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
89+ expect(identity?.displayName).toBe("t3tools/t3code");
90+ }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
91+ );
92+
93+ it.effect("uses the last remote path segment as the repository name for nested groups", () =>
94+ Effect.gen(function* () {
95+ const fileSystem = yield* FileSystem.FileSystem;
96+ const cwd = yield* fileSystem.makeTempDirectoryScoped({
97+ prefix: "t3-repository-identity-nested-group-test-",
98+ });
99+
100+ yield* git(cwd, ["init"]);
101+ yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]);
102+
103+ const resolver = yield* RepositoryIdentityResolver;
104+ const identity = yield* resolver.resolve(cwd);
105+
106+ expect(identity).not.toBeNull();
107+ expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code");
108+ expect(identity?.displayName).toBe("t3tools/platform/t3code");
109+ expect(identity?.owner).toBe("t3tools");
110+ expect(identity?.name).toBe("t3code");
111+ }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
112+ );
113+
114+ it.effect(
115+ "refreshes cached null identities after the negative TTL when a remote is configured later",
116+ () =>
117+ Effect.gen(function* () {
118+ const fileSystem = yield* FileSystem.FileSystem;
119+ const cwd = yield* fileSystem.makeTempDirectoryScoped({
120+ prefix: "t3-repository-identity-late-remote-test-",
121+ });
122+
123+ yield* git(cwd, ["init"]);
124+
125+ const resolver = yield* RepositoryIdentityResolver;
126+ const initialIdentity = yield* resolver.resolve(cwd);
127+ expect(initialIdentity).toBeNull();
128+
129+ yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
130+
131+ const cachedIdentity = yield* resolver.resolve(cwd);
132+ expect(cachedIdentity).toBeNull();
133+
134+ yield* TestClock.adjust(Duration.millis(120));
135+
136+ const refreshedIdentity = yield* resolver.resolve(cwd);
137+ expect(refreshedIdentity).not.toBeNull();
138+ expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
139+ expect(refreshedIdentity?.name).toBe("t3code");
140+ }).pipe(
141+ Effect.provide(
142+ Layer.merge(
143+ TestClock.layer(),
144+ makeRepositoryIdentityResolverTestLayer({
145+ negativeCacheTtl: Duration.millis(50),
146+ positiveCacheTtl: Duration.seconds(1),
147+ }),
148+ ),
149+ ),
150+ ),
151+ );
152+
153+ it.effect("refreshes cached identities after the positive TTL when a remote changes", () =>
154+ Effect.gen(function* () {
155+ const fileSystem = yield* FileSystem.FileSystem;
156+ const cwd = yield* fileSystem.makeTempDirectoryScoped({
157+ prefix: "t3-repository-identity-remote-change-test-",
158+ });
159+
160+ yield* git(cwd, ["init"]);
161+ yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
162+
163+ const resolver = yield* RepositoryIdentityResolver;
164+ const initialIdentity = yield* resolver.resolve(cwd);
165+ expect(initialIdentity).not.toBeNull();
166+ expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
167+
168+ yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]);
169+
170+ const cachedIdentity = yield* resolver.resolve(cwd);
171+ expect(cachedIdentity).not.toBeNull();
172+ expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
173+
174+ yield* TestClock.adjust(Duration.millis(180));
175+
176+ const refreshedIdentity = yield* resolver.resolve(cwd);
177+ expect(refreshedIdentity).not.toBeNull();
178+ expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next");
179+ expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next");
180+ expect(refreshedIdentity?.name).toBe("t3code-next");
181+ }).pipe(
182+ Effect.provide(
183+ Layer.merge(
184+ TestClock.layer(),
185+ makeRepositoryIdentityResolverTestLayer({
186+ negativeCacheTtl: Duration.millis(50),
187+ positiveCacheTtl: Duration.millis(100),
188+ }),
189+ ),
190+ ),
191+ ),
192+ );
193+});
apps/server/src/project/Layers/RepositoryIdentityResolver.ts+147−0
@@ -0,0 +1,147 @@
1+import type { RepositoryIdentity } from "@t3tools/contracts";
2+import { Cache, Duration, Effect, Exit, Layer } from "effect";
3+import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git";
4+
5+import { runProcess } from "../../processRunner.ts";
6+import {
7+ RepositoryIdentityResolver,
8+ type RepositoryIdentityResolverShape,
9+} from "../Services/RepositoryIdentityResolver.ts";
10+
11+function parseRemoteFetchUrls(stdout: string): Map<string, string> {
12+ const remotes = new Map<string, string>();
13+ for (const line of stdout.split("\n")) {
14+ const trimmed = line.trim();
15+ if (trimmed.length === 0) continue;
16+ const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
17+ if (!match) continue;
18+ const [, remoteName = "", remoteUrl = "", direction = ""] = match;
19+ if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) {
20+ continue;
21+ }
22+ remotes.set(remoteName, remoteUrl);
23+ }
24+ return remotes;
25+}
26+
27+function pickPrimaryRemote(
28+ remotes: ReadonlyMap<string, string>,
29+): { readonly remoteName: string; readonly remoteUrl: string } | null {
30+ for (const preferredRemoteName of ["upstream", "origin"] as const) {
31+ const remoteUrl = remotes.get(preferredRemoteName);
32+ if (remoteUrl) {
33+ return { remoteName: preferredRemoteName, remoteUrl };
34+ }
35+ }
36+
37+ const [remoteName, remoteUrl] =
38+ [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? [];
39+ return remoteName && remoteUrl ? { remoteName, remoteUrl } : null;
40+}
41+
42+function buildRepositoryIdentity(input: {
43+ readonly remoteName: string;
44+ readonly remoteUrl: string;
45+}): RepositoryIdentity {
46+ const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
47+ const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl);
48+ const repositoryPath = canonicalKey.split("/").slice(1).join("/");
49+ const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);
50+ const [owner] = repositoryPathSegments;
51+ const repositoryName = repositoryPathSegments.at(-1);
52+
53+ return {
54+ canonicalKey,
55+ locator: {
56+ source: "git-remote",
57+ remoteName: input.remoteName,
58+ remoteUrl: input.remoteUrl,
59+ },
60+ ...(repositoryPath ? { displayName: repositoryPath } : {}),
61+ ...(hostingProvider ? { provider: hostingProvider.kind } : {}),
62+ ...(owner ? { owner } : {}),
63+ ...(repositoryName ? { name: repositoryName } : {}),
64+ };
65+}
66+
67+const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
68+const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);
69+const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10);
70+
71+interface RepositoryIdentityResolverOptions {
72+ readonly cacheCapacity?: number;
73+ readonly positiveCacheTtl?: Duration.Input;
74+ readonly negativeCacheTtl?: Duration.Input;
75+}
76+
77+async function resolveRepositoryIdentityCacheKey(cwd: string): Promise<string> {
78+ let cacheKey = cwd;
79+
80+ try {
81+ const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
82+ allowNonZeroExit: true,
83+ });
84+ if (topLevelResult.code !== 0) {
85+ return cacheKey;
86+ }
87+
88+ const candidate = topLevelResult.stdout.trim();
89+ if (candidate.length > 0) {
90+ cacheKey = candidate;
91+ }
92+ } catch {
93+ return cacheKey;
94+ }
95+
96+ return cacheKey;
97+}
98+
99+async function resolveRepositoryIdentityFromCacheKey(
100+ cacheKey: string,
101+): Promise<RepositoryIdentity | null> {
102+ try {
103+ const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], {
104+ allowNonZeroExit: true,
105+ });
106+ if (remoteResult.code !== 0) {
107+ return null;
108+ }
109+
110+ const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout));
111+ return remote ? buildRepositoryIdentity(remote) : null;
112+ } catch {
113+ return null;
114+ }
115+}
116+
117+export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")(
118+ function* (options: RepositoryIdentityResolverOptions = {}) {
119+ const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>({
120+ capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY,
121+ lookup: (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)),
122+ timeToLive: Exit.match({
123+ onSuccess: (value) =>
124+ value === null
125+ ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL)
126+ : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL),
127+ onFailure: () => Duration.zero,
128+ }),
129+ });
130+
131+ const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn(
132+ "RepositoryIdentityResolver.resolve",
133+ )(function* (cwd) {
134+ const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd));
135+ return yield* Cache.get(repositoryIdentityCache, cacheKey);
136+ });
137+
138+ return {
139+ resolve,
140+ } satisfies RepositoryIdentityResolverShape;
141+ },
142+);
143+
144+export const RepositoryIdentityResolverLive = Layer.effect(
145+ RepositoryIdentityResolver,
146+ makeRepositoryIdentityResolver(),
147+);
apps/server/src/project/Services/RepositoryIdentityResolver.ts+12−0
@@ -0,0 +1,12 @@
1+import type { RepositoryIdentity } from "@t3tools/contracts";
2+import { ServiceMap } from "effect";
3+import type { Effect } from "effect";
4+
5+export interface RepositoryIdentityResolverShape {
6+ readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>;
7+}
8+
9+export class RepositoryIdentityResolver extends ServiceMap.Service<
10+ RepositoryIdentityResolver,
11+ RepositoryIdentityResolverShape
12+>()("t3/project/Services/RepositoryIdentityResolver") {}
apps/server/src/server.test.ts+245−1
@@ -5,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; · 4 unmodified lines
55 import {
66 CommandId,
77 DEFAULT_SERVER_SETTINGS,
8+ EnvironmentId,
9+ EventId,
810 GitCommandError,
911 KeybindingRule,
1012 MessageId,
@@ -83,6 +85,14 @@ import { · 72 unmodified lines
8385 ProjectSetupScriptRunner,
8486 type ProjectSetupScriptRunnerShape,
8587 } from "./project/Services/ProjectSetupScriptRunner.ts";
88+import {
89+ RepositoryIdentityResolver,
90+ type RepositoryIdentityResolverShape,
91+} from "./project/Services/RepositoryIdentityResolver.ts";
92+import {
93+ ServerEnvironment,
94+ type ServerEnvironmentShape,
95+} from "./environment/Services/ServerEnvironment.ts";
8696 import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
8797 import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
8898 import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
@@ -93,6 +103,18 @@ const defaultModelSelection = { · 4 unmodified lines
93103 provider: "codex",
94104 model: "gpt-5-codex",
95105 } as const;
106+const testEnvironmentDescriptor = {
107+ environmentId: EnvironmentId.makeUnsafe("environment-test"),
108+ label: "Test environment",
109+ platform: {
110+ os: "darwin" as const,
111+ arch: "arm64" as const,
112+ },
113+ serverVersion: "0.0.0-test",
114+ capabilities: {
115+ repositoryIdentity: true,
116+ },
117+};
96118
97119 const makeDefaultOrchestrationReadModel = () => {
98120 const now = new Date().toISOString();
@@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: { · 171 unmodified lines
270292 browserTraceCollector?: Partial<BrowserTraceCollectorShape>;
271293 serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>;
272294 serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>;
295+ serverEnvironment?: Partial<ServerEnvironmentShape>;
296+ repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>;
273297 };
274298 }) =>
275299 Effect.gen(function* () {
@@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: { · 140 unmodified lines
416440 ...options?.layers?.serverRuntimeStartup,
417441 }),
418442 ),
443+ Layer.provide(
444+ Layer.mock(ServerEnvironment)({
445+ getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId),
446+ getDescriptor: Effect.succeed(testEnvironmentDescriptor),
447+ ...options?.layers?.serverEnvironment,
448+ }),
449+ ),
450+ Layer.provide(
451+ Layer.mock(RepositoryIdentityResolver)({
452+ resolve: () => Effect.succeed(null),
453+ ...options?.layers?.repositoryIdentityResolver,
454+ }),
455+ ),
419456 Layer.provide(workspaceAndProjectServicesLayer),
420457 Layer.provideMerge(FetchHttpClient.layer),
421458 Layer.provide(layerConfig),
@@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 647 unmodified lines
10691106 sequence: 1,
10701107 type: "welcome" as const,
10711108 payload: {
1109+ environment: testEnvironmentDescriptor,
10721110 cwd: "/tmp/project",
10731111 projectName: "project",
10741112 },
@@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 3 unmodified lines
10781116 version: 1 as const,
10791117 sequence: 2,
10801118 type: "ready" as const,
1081− payload: { at: new Date().toISOString() },
1119+ payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor },
10821120 });
10831121
10841122 yield* buildAppUnderTest({
@@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 911 unmodified lines
19962034 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
19972035 );
19982036
2037+ it.effect("enriches replayed project events with repository identity metadata", () =>
2038+ Effect.gen(function* () {
2039+ const repositoryIdentity = {
2040+ canonicalKey: "github.com/t3tools/t3code",
2041+ locator: {
2042+ source: "git-remote" as const,
2043+ remoteName: "origin",
2044+ remoteUrl: "git@github.com:T3Tools/t3code.git",
2045+ },
2046+ displayName: "T3Tools/t3code",
2047+ provider: "github",
2048+ owner: "T3Tools",
2049+ name: "t3code",
2050+ };
2051+
2052+ yield* buildAppUnderTest({
2053+ layers: {
2054+ orchestrationEngine: {
2055+ readEvents: (_fromSequenceExclusive) =>
2056+ Stream.make({
2057+ sequence: 1,
2058+ eventId: EventId.makeUnsafe("event-1"),
2059+ aggregateKind: "project",
2060+ aggregateId: defaultProjectId,
2061+ occurredAt: "2026-04-05T00:00:00.000Z",
2062+ commandId: null,
2063+ causationEventId: null,
2064+ correlationId: null,
2065+ metadata: {},
2066+ type: "project.created",
2067+ payload: {
2068+ projectId: defaultProjectId,
2069+ title: "Default Project",
2070+ workspaceRoot: "/tmp/default-project",
2071+ defaultModelSelection,
2072+ scripts: [],
2073+ createdAt: "2026-04-05T00:00:00.000Z",
2074+ updatedAt: "2026-04-05T00:00:00.000Z",
2075+ },
2076+ } satisfies Extract<OrchestrationEvent, { type: "project.created" }>),
2077+ },
2078+ repositoryIdentityResolver: {
2079+ resolve: () => Effect.succeed(repositoryIdentity),
2080+ },
2081+ },
2082+ });
2083+
2084+ const wsUrl = yield* getWsServerUrl("/ws");
2085+ const replayResult = yield* Effect.scoped(
2086+ withWsRpcClient(wsUrl, (client) =>
2087+ client[ORCHESTRATION_WS_METHODS.replayEvents]({
2088+ fromSequenceExclusive: 0,
2089+ }),
2090+ ),
2091+ );
2092+
2093+ const replayedEvent = replayResult[0];
2094+ assert.equal(replayedEvent?.type, "project.created");
2095+ assert.deepEqual(
2096+ replayedEvent && replayedEvent.type === "project.created"
2097+ ? replayedEvent.payload.repositoryIdentity
2098+ : null,
2099+ repositoryIdentity,
2100+ );
2101+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2102+ );
2103+
19992104 it.effect("closes thread terminals after a successful archive command", () =>
20002105 Effect.gen(function* () {
20012106 const threadId = ThreadId.makeUnsafe("thread-archive");
@@ -2498,6 +2603,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 496 unmodified lines
24982603 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
24992604 );
25002605
2606+ it.effect("enriches replayed project events only once before streaming them to subscribers", () =>
2607+ Effect.gen(function* () {
2608+ let resolveCalls = 0;
2609+ const repositoryIdentity = {
2610+ canonicalKey: "github.com/t3tools/t3code",
2611+ locator: {
2612+ source: "git-remote" as const,
2613+ remoteName: "origin",
2614+ remoteUrl: "git@github.com:t3tools/t3code.git",
2615+ },
2616+ displayName: "t3tools/t3code",
2617+ provider: "github" as const,
2618+ owner: "t3tools",
2619+ name: "t3code",
2620+ };
2621+
2622+ yield* buildAppUnderTest({
2623+ layers: {
2624+ orchestrationEngine: {
2625+ getReadModel: () =>
2626+ Effect.succeed({
2627+ ...makeDefaultOrchestrationReadModel(),
2628+ snapshotSequence: 0,
2629+ }),
2630+ readEvents: () =>
2631+ Stream.make({
2632+ sequence: 1,
2633+ eventId: EventId.makeUnsafe("event-1"),
2634+ aggregateKind: "project",
2635+ aggregateId: defaultProjectId,
2636+ occurredAt: "2026-04-06T00:00:00.000Z",
2637+ commandId: null,
2638+ causationEventId: null,
2639+ correlationId: null,
2640+ metadata: {},
2641+ type: "project.meta-updated",
2642+ payload: {
2643+ projectId: defaultProjectId,
2644+ title: "Replayed Project",
2645+ updatedAt: "2026-04-06T00:00:00.000Z",
2646+ },
2647+ } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>),
2648+ streamDomainEvents: Stream.empty,
2649+ },
2650+ repositoryIdentityResolver: {
2651+ resolve: () => {
2652+ resolveCalls += 1;
2653+ return Effect.succeed(repositoryIdentity);
2654+ },
2655+ },
2656+ },
2657+ });
2658+
2659+ const wsUrl = yield* getWsServerUrl("/ws");
2660+ const events = yield* Effect.scoped(
2661+ withWsRpcClient(wsUrl, (client) =>
2662+ client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
2663+ Stream.take(1),
2664+ Stream.runCollect,
2665+ ),
2666+ ),
2667+ );
2668+
2669+ const event = Array.from(events)[0];
2670+ assert.equal(resolveCalls, 1);
2671+ assert.equal(event?.type, "project.meta-updated");
2672+ assert.deepEqual(
2673+ event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null,
2674+ repositoryIdentity,
2675+ );
2676+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2677+ );
2678+
2679+ it.effect("enriches subscribed project meta updates with repository identity metadata", () =>
2680+ Effect.gen(function* () {
2681+ const repositoryIdentity = {
2682+ canonicalKey: "github.com/t3tools/t3code",
2683+ locator: {
2684+ source: "git-remote" as const,
2685+ remoteName: "upstream",
2686+ remoteUrl: "git@github.com:T3Tools/t3code.git",
2687+ },
2688+ displayName: "T3Tools/t3code",
2689+ provider: "github",
2690+ owner: "T3Tools",
2691+ name: "t3code",
2692+ };
2693+
2694+ yield* buildAppUnderTest({
2695+ layers: {
2696+ orchestrationEngine: {
2697+ getReadModel: () =>
2698+ Effect.succeed({
2699+ ...makeDefaultOrchestrationReadModel(),
2700+ snapshotSequence: 0,
2701+ }),
2702+ streamDomainEvents: Stream.make({
2703+ sequence: 1,
2704+ eventId: EventId.makeUnsafe("event-1"),
2705+ aggregateKind: "project",
2706+ aggregateId: defaultProjectId,
2707+ occurredAt: "2026-04-05T00:00:00.000Z",
2708+ commandId: null,
2709+ causationEventId: null,
2710+ correlationId: null,
2711+ metadata: {},
2712+ type: "project.meta-updated",
2713+ payload: {
2714+ projectId: defaultProjectId,
2715+ title: "Renamed Project",
2716+ updatedAt: "2026-04-05T00:00:00.000Z",
2717+ },
2718+ } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>),
2719+ },
2720+ repositoryIdentityResolver: {
2721+ resolve: () => Effect.succeed(repositoryIdentity),
2722+ },
2723+ },
2724+ });
2725+
2726+ const wsUrl = yield* getWsServerUrl("/ws");
2727+ const events = yield* Effect.scoped(
2728+ withWsRpcClient(wsUrl, (client) =>
2729+ client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
2730+ Stream.take(1),
2731+ Stream.runCollect,
2732+ ),
2733+ ),
2734+ );
2735+
2736+ const event = Array.from(events)[0];
2737+ assert.equal(event?.type, "project.meta-updated");
2738+ assert.deepEqual(
2739+ event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null,
2740+ repositoryIdentity,
2741+ );
2742+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2743+ );
2744+
25012745 it.effect("routes websocket rpc orchestration.getSnapshot errors", () =>
25022746 Effect.gen(function* () {
25032747 yield* buildAppUnderTest({
apps/server/src/server.ts+4−0
@@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" · 43 unmodified lines
4444 import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
4545 import { ServerSettingsLive } from "./serverSettings";
4646 import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver";
47+import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver";
4748 import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries";
4849 import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem";
4950 import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";
5051 import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner";
5152 import { ObservabilityLive } from "./observability/Layers/Observability";
53+import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment";
5254
5355 const PtyAdapterLive = Layer.unwrap(
5456 Effect.gen(function* () {
@@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( · 144 unmodified lines
199201 Layer.provideMerge(ServerSettingsLive),
200202 Layer.provideMerge(WorkspaceLayerLive),
201203 Layer.provideMerge(ProjectFaviconResolverLive),
204+ Layer.provideMerge(RepositoryIdentityResolverLive),
205+ Layer.provideMerge(ServerEnvironmentLive),
202206
203207 // Misc.
204208 Layer.provideMerge(AnalyticsServiceLayerLive),
apps/server/src/serverLifecycleEvents.test.ts+10−0
@@ -1,3 +1,4 @@
1+import { EnvironmentId } from "@t3tools/contracts";
12 import { assert, it } from "@effect/vitest";
23 import { assertTrue } from "@effect/vitest/utils";
34 import { Effect, Option } from "effect";
@@ -9,12 +10,20 @@ it.effect( · 5 unmodified lines
910 () =>
1011 Effect.gen(function* () {
1112 const lifecycleEvents = yield* ServerLifecycleEvents;
13+ const environment = {
14+ environmentId: EnvironmentId.makeUnsafe("environment-test"),
15+ label: "Test environment",
16+ platform: { os: "darwin" as const, arch: "arm64" as const },
17+ serverVersion: "0.0.0-test",
18+ capabilities: { repositoryIdentity: true },
19+ };
1220
1321 const welcome = yield* lifecycleEvents
1422 .publish({
1523 version: 1,
1624 type: "welcome",
1725 payload: {
26+ environment,
1827 cwd: "/tmp/project",
1928 projectName: "project",
2029 },
@@ -29,6 +38,7 @@ it.effect( · 8 unmodified lines
2938 type: "ready",
3039 payload: {
3140 at: new Date().toISOString(),
41+ environment,
3242 },
3343 })
3444 .pipe(Effect.timeoutOption("50 millis"));
apps/server/src/serverRuntimeStartup.ts+12−2
@@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap · 26 unmodified lines
2727 import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
2828 import { ServerLifecycleEvents } from "./serverLifecycleEvents";
2929 import { ServerSettingsService } from "./serverSettings";
30+import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
3031 import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3132
3233 const isWildcardHost = (host: string | undefined): boolean =>
@@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 229 unmodified lines
262263 const orchestrationReactor = yield* OrchestrationReactor;
263264 const lifecycleEvents = yield* ServerLifecycleEvents;
264265 const serverSettings = yield* ServerSettingsService;
266+ const serverEnvironment = yield* ServerEnvironment;
265267
266268 const commandGate = yield* makeCommandGate;
267269 const httpListening = yield* Deferred.make<void>();
@@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 40 unmodified lines
308310
309311 yield* Effect.logDebug("startup phase: preparing welcome payload");
310312 const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome);
313+ const environment = yield* serverEnvironment.getDescriptor;
311314 yield* Effect.logDebug("startup phase: publishing welcome event", {
315+ environmentId: environment.environmentId,
312316 cwd: welcome.cwd,
313317 projectName: welcome.projectName,
314318 bootstrapProjectId: welcome.bootstrapProjectId,
@@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 4 unmodified lines
319323 lifecycleEvents.publish({
320324 version: 1,
321325 type: "welcome",
322− payload: welcome,
326+ payload: {
327+ environment,
328+ ...welcome,
329+ },
323330 }),
324331 );
325332 }).pipe(
@@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 28 unmodified lines
354361 lifecycleEvents.publish({
355362 version: 1,
356363 type: "ready",
357− payload: { at: new Date().toISOString() },
364+ payload: {
365+ at: new Date().toISOString(),
366+ environment: yield* serverEnvironment.getDescriptor,
367+ },
358368 }),
359369 );
360370
apps/server/src/ws.ts+55−1
@@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; · 46 unmodified lines
4747 import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem";
4848 import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths";
4949 import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner";
50+import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver";
51+import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
5052
5153 const WsRpcLayer = WsRpcGroup.toLayer(
5254 Effect.gen(function* () {
@@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 14 unmodified lines
6769 const workspaceEntries = yield* WorkspaceEntries;
6870 const workspaceFileSystem = yield* WorkspaceFileSystem;
6971 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
72+ const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
73+ const serverEnvironment = yield* ServerEnvironment;
7074 const serverCommandId = (tag: string) =>
7175 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
7276
@@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 40 unmodified lines
113117 });
114118 };
115119
120+ const enrichProjectEvent = (
121+ event: OrchestrationEvent,
122+ ): Effect.Effect<OrchestrationEvent, never, never> => {
123+ switch (event.type) {
124+ case "project.created":
125+ return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe(
126+ Effect.map((repositoryIdentity) => ({
127+ ...event,
128+ payload: {
129+ ...event.payload,
130+ repositoryIdentity,
131+ },
132+ })),
133+ );
134+ case "project.meta-updated":
135+ return Effect.gen(function* () {
136+ const workspaceRoot =
137+ event.payload.workspaceRoot ??
138+ (yield* orchestrationEngine.getReadModel()).projects.find(
139+ (project) => project.id === event.payload.projectId,
140+ )?.workspaceRoot ??
141+ null;
142+ if (workspaceRoot === null) {
143+ return event;
144+ }
145+
146+ const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot);
147+ return {
148+ ...event,
149+ payload: {
150+ ...event.payload,
151+ repositoryIdentity,
152+ },
153+ } satisfies OrchestrationEvent;
154+ });
155+ default:
156+ return Effect.succeed(event);
157+ }
158+ };
159+
160+ const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) =>
161+ Effect.forEach(events, enrichProjectEvent, { concurrency: 4 });
162+
116163 const dispatchBootstrapTurnStart = (
117164 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,
118165 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>
@@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 210 unmodified lines
329376 const keybindingsConfig = yield* keybindings.loadConfigState;
330377 const providers = yield* providerRegistry.getProviders;
331378 const settings = yield* serverSettings.getSettings;
379+ const environment = yield* serverEnvironment.getDescriptor;
332380
333381 return {
382+ environment,
334383 cwd: config.cwd,
335384 keybindingsConfigPath: config.keybindingsConfigPath,
336385 keybindings: keybindingsConfig.keybindings,
@@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 98 unmodified lines
435484 ),
436485 ).pipe(
437486 Effect.map((events) => Array.from(events)),
487+ Effect.flatMap(enrichOrchestrationEvents),
438488 Effect.mapError(
439489 (cause) =>
440490 new OrchestrationReplayEventsError({
@@ -455,10 +505,14 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 14 unmodified lines
455505 orchestrationEngine.readEvents(fromSequenceExclusive),
456506 ).pipe(
457507 Effect.map((events) => Array.from(events)),
508+ Effect.flatMap(enrichOrchestrationEvents),
458509 Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),
459510 );
460511 const replayStream = Stream.fromIterable(replayEvents);
461− const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents);
512+ const liveStream = orchestrationEngine.streamDomainEvents.pipe(
513+ Stream.mapEffect(enrichProjectEvent),
514+ );
515+ const source = Stream.merge(replayStream, liveStream);
462516 type SequenceState = {
463517 readonly nextSequence: number;
464518 readonly pendingBySequence: Map<number, OrchestrationEvent>;
apps/web/index.html+1−0
@@ -12,6 +12,7 @@ · 11 unmodified lines
1212 rel="stylesheet"
1313 />
1414 <title>T3 Code (Alpha)</title>
15+ <script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>
1516 </head>
1617 <body>
1718 <div id="root"></div>
apps/web/package.json+1−0
@@ -23,6 +23,7 @@ · 22 unmodified lines
2323 "@formkit/auto-animate": "^0.9.0",
2424 "@lexical/react": "^0.41.0",
2525 "@pierre/diffs": "^1.1.0-beta.16",
26+ "@t3tools/client-runtime": "workspace:*",
2627 "@t3tools/contracts": "workspace:*",
2728 "@t3tools/shared": "workspace:*",
2829 "@tanstack/react-pacer": "^0.19.4",
apps/web/src/components/BranchToolbar.tsx+36−14
@@ -1,11 +1,13 @@
1−import type { ThreadId } from "@t3tools/contracts";
1+import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
23 import { FolderIcon, GitForkIcon } from "lucide-react";
3−import { useCallback } from "react";
4+import { useCallback, useMemo } from "react";
45
6+import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
7+import { readEnvironmentApi } from "../environmentApi";
58 import { newCommandId } from "../lib/utils";
6−import { readNativeApi } from "../nativeApi";
7−import { useComposerDraftStore } from "../composerDraftStore";
89 import { useStore } from "../store";
10+import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
911 import {
1012 EnvMode,
1113 resolveDraftEnvModeAfterBranchChange,
@@ -20,7 +22,9 @@ const envModeItems = [ · 8 unmodified lines
2022 ] as const;
2123
2224 interface BranchToolbarProps {
25+ environmentId: EnvironmentId;
2326 threadId: ThreadId;
27+ draftId?: DraftId;
2428 onEnvModeChange: (mode: EnvMode) => void;
2529 envLocked: boolean;
2630 onCheckoutPullRequestRequest?: (reference: string) => void;
@@ -28,21 +32,34 @@ interface BranchToolbarProps { · 1 unmodified line
2832 }
2933
3034 export default function BranchToolbar({
35+ environmentId,
3136 threadId,
37+ draftId,
3238 onEnvModeChange,
3339 envLocked,
3440 onCheckoutPullRequestRequest,
3541 onComposerFocusRequest,
3642 }: BranchToolbarProps) {
37− const serverThread = useStore((store) => store.threadShellById[threadId]);
38− const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null);
43+ const threadRef = useMemo(
44+ () => scopeThreadRef(environmentId, threadId),
45+ [environmentId, threadId],
46+ );
47+ const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]);
48+ const serverThread = useStore(serverThreadSelector);
49+ const serverSession = serverThread?.session ?? null;
3950 const setThreadBranchAction = useStore((store) => store.setThreadBranch);
40− const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));
51+ const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
4152 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
42− const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null;
43− const activeProject = useStore((store) =>
44− activeProjectId ? store.projectById[activeProjectId] : undefined,
53+ const activeProjectRef = serverThread
54+ ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
55+ : draftThread
56+ ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
57+ : null;
58+ const activeProjectSelector = useMemo(
59+ () => createProjectSelectorByRef(activeProjectRef),
60+ [activeProjectRef],
4561 );
62+ const activeProject = useStore(activeProjectSelector);
4663 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
4764 const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
4865 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
@@ -56,8 +73,8 @@ export default function BranchToolbar({ · 7 unmodified lines
5673
5774 const setThreadBranch = useCallback(
5875 (branch: string | null, worktreePath: string | null) => {
59− if (!activeThreadId) return;
60− const api = readNativeApi();
76+ if (!activeThreadId || !activeProject) return;
77+ const api = readEnvironmentApi(environmentId);
6178 // If the effective cwd is about to change, stop the running session so the
6279 // next message creates a new one with the correct cwd.
6380 if (serverSession && worktreePath !== activeWorktreePath && api) {
@@ -88,20 +105,24 @@ export default function BranchToolbar({ · 24 unmodified lines
88105 currentWorktreePath: activeWorktreePath,
89106 effectiveEnvMode,
90107 });
91− setDraftThreadContext(threadId, {
108+ setDraftThreadContext(draftId ?? threadRef, {
92109 branch,
93110 worktreePath,
94111 envMode: nextDraftEnvMode,
112+ projectRef: scopeProjectRef(environmentId, activeProject.id),
95113 });
96114 },
97115 [
98116 activeThreadId,
117+ activeProject,
99118 serverSession,
100119 activeWorktreePath,
101120 hasServerThread,
102121 setThreadBranchAction,
103122 setDraftThreadContext,
104− threadId,
123+ draftId,
124+ threadRef,
125+ environmentId,
105126 effectiveEnvMode,
106127 ],
107128 );
@@ -156,6 +177,7 @@ export default function BranchToolbar({ · 48 unmodified lines
156177 )}
157178
158179 <BranchToolbarBranchSelector
180+ environmentId={environmentId}
159181 activeProjectCwd={activeProject.cwd}
160182 activeThreadBranch={activeThreadBranch}
161183 activeWorktreePath={activeWorktreePath}
apps/web/src/components/BranchToolbarBranchSelector.tsx+13−10
@@ -1,4 +1,4 @@
1−import type { GitBranch } from "@t3tools/contracts";
1+import type { EnvironmentId, GitBranch } from "@t3tools/contracts";
22 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
33 import { useVirtualizer } from "@tanstack/react-virtual";
44 import { ChevronDownIcon } from "lucide-react";
@@ -16,7 +16,7 @@ import { · 11 unmodified lines
1616
1717 import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery";
1818 import { useGitStatus } from "../lib/gitStatusState";
19−import { readNativeApi } from "../nativeApi";
19+import { readEnvironmentApi } from "../environmentApi";
2020 import { parsePullRequestReference } from "../pullRequestReference";
2121 import {
2222 deriveLocalBranchNameFromRemoteRef,
@@ -39,6 +39,7 @@ import { · 16 unmodified lines
3939 import { toastManager } from "./ui/toast";
4040
4141 interface BranchToolbarBranchSelectorProps {
42+ environmentId: EnvironmentId;
4243 activeProjectCwd: string;
4344 activeThreadBranch: string | null;
4445 activeWorktreePath: string | null;
@@ -70,6 +71,7 @@ function getBranchTriggerLabel(input: { · 25 unmodified lines
7071 }
7172
7273 export function BranchToolbarBranchSelector({
74+ environmentId,
7375 activeProjectCwd,
7476 activeThreadBranch,
7577 activeWorktreePath,
@@ -85,16 +87,16 @@ export function BranchToolbarBranchSelector({ · 9 unmodified lines
8587 const [branchQuery, setBranchQuery] = useState("");
8688 const deferredBranchQuery = useDeferredValue(branchQuery);
8789
88− const branchStatusQuery = useGitStatus(branchCwd);
90+ const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });
8991 const trimmedBranchQuery = branchQuery.trim();
9092 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
9193
9294 useEffect(() => {
9395 if (!branchCwd) return;
9496 void queryClient.prefetchInfiniteQuery(
95− gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }),
97+ gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }),
9698 );
97− }, [branchCwd, queryClient]);
99+ }, [branchCwd, environmentId, queryClient]);
98100
99101 const {
100102 data: branchesSearchData,
@@ -104,6 +106,7 @@ export function BranchToolbarBranchSelector({ · 3 unmodified lines
104106 isPending: isBranchesSearchPending,
105107 } = useInfiniteQuery(
106108 gitBranchSearchInfiniteQueryOptions({
109+ environmentId,
107110 cwd: branchCwd,
108111 query: deferredTrimmedBranchQuery,
109112 }),
@@ -184,13 +187,13 @@ export function BranchToolbarBranchSelector({ · 74 unmodified lines
184187 startBranchActionTransition(async () => {
185188 await action().catch(() => undefined);
186189 await queryClient
187− .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) })
190+ .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) })
188191 .catch(() => undefined);
189192 });
190193 };
191194
192195 const selectBranch = (branch: GitBranch) => {
193− const api = readNativeApi();
196+ const api = readEnvironmentApi(environmentId);
194197 if (!api || !branchCwd || isBranchActionPending) return;
195198
196199 // In new-worktree mode, selecting a branch sets the base branch.
@@ -248,7 +251,7 @@ export function BranchToolbarBranchSelector({ · 51 unmodified lines
248251
249252 const createBranch = (rawName: string) => {
250253 const name = rawName.trim();
251− const api = readNativeApi();
254+ const api = readEnvironmentApi(environmentId);
252255 if (!api || !branchCwd || !name || isBranchActionPending) return;
253256
254257 setIsBranchMenuOpen(false);
@@ -302,10 +305,10 @@ export function BranchToolbarBranchSelector({ · 47 unmodified lines
302305 return;
303306 }
304307 void queryClient.invalidateQueries({
305− queryKey: gitQueryKeys.branches(branchCwd),
308+ queryKey: gitQueryKeys.branches(environmentId, branchCwd),
306309 });
307310 },
308− [branchCwd, queryClient],
311+ [branchCwd, environmentId, queryClient],
309312 );
310313
311314 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
apps/web/src/components/ChatMarkdown.tsx+2−2
@@ -22,7 +22,7 @@ import { fnv1a32 } from "../lib/diffRendering"; · 21 unmodified lines
2222 import { LRUCache } from "../lib/lruCache";
2323 import { useTheme } from "../hooks/useTheme";
2424 import { resolveMarkdownFileLinkTarget } from "../markdown-links";
25−import { readNativeApi } from "../nativeApi";
25+import { readLocalApi } from "../localApi";
2626
2727 class CodeHighlightErrorBoundary extends React.Component<
2828 { fallback: ReactNode; children: ReactNode },
@@ -253,7 +253,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { · 224 unmodified lines
253253 onClick={(event) => {
254254 event.preventDefault();
255255 event.stopPropagation();
256− const api = readNativeApi();
256+ const api = readLocalApi();
257257 if (api) {
258258 void openInPreferredEditor(api, targetPath);
259259 } else {
apps/web/src/components/ChatView.browser.tsx+330−229
@@ -4,6 +4,7 @@ import "../index.css"; · 3 unmodified lines
44 import {
55 EventId,
66 ORCHESTRATION_WS_METHODS,
7+ EnvironmentId,
78 type MessageId,
89 type OrchestrationEvent,
910 type OrchestrationReadModel,
@@ -12,11 +13,16 @@ import { · 2 unmodified lines
1213 type ServerLifecycleWelcomePayload,
1314 type ThreadId,
1415 type TurnId,
15− type UserInputQuestion,
1616 WS_METHODS,
1717 OrchestrationSessionStatus,
1818 DEFAULT_SERVER_SETTINGS,
1919 } from "@t3tools/contracts";
20+import {
21+ scopedProjectKey,
22+ scopedThreadKey,
23+ scopeProjectRef,
24+ scopeThreadRef,
25+} from "@t3tools/client-runtime";
2026 import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
2127 import { HttpResponse, http, ws } from "msw";
2228 import { setupWorker } from "msw/browser";
@@ -24,19 +30,20 @@ import { page } from "vitest/browser"; · 1 unmodified line
2430 import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2531 import { render } from "vitest-browser-react";
2632
27−import { useComposerDraftStore } from "../composerDraftStore";
33+import { useComposerDraftStore, DraftId } from "../composerDraftStore";
2834 import {
2935 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
3036 type TerminalContextDraft,
3137 removeInlineTerminalContextPlaceholder,
3238 } from "../lib/terminalContext";
3339 import { isMacPlatform } from "../lib/utils";
34−import { __resetNativeApiForTests } from "../nativeApi";
40+import { __resetLocalApiForTests } from "../localApi";
3541 import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
3642 import { getServerConfig } from "../rpc/serverState";
3743 import { getRouter } from "../router";
38−import { useStore } from "../store";
44+import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
3945 import { useTerminalStateStore } from "../terminalStateStore";
46+import { useUiStateStore } from "../uiStateStore";
4047 import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
4148 import { estimateTimelineMessageHeight } from "./timelineHeight";
4249 import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
@@ -49,8 +56,13 @@ vi.mock("../lib/gitStatusState", () => ({ · 6 unmodified lines
4956 }));
5057
5158 const THREAD_ID = "thread-browser-test" as ThreadId;
52−const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5359 const PROJECT_ID = "project-1" as ProjectId;
60+const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
61+const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);
62+const THREAD_KEY = scopedThreadKey(THREAD_REF);
63+const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
64+const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`;
65+const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID));
5466 const NOW_ISO = "2026-03-04T12:00:00.000Z";
5567 const BASE_TIME_MS = Date.parse(NOW_ISO);
5668 const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>";
@@ -129,6 +141,13 @@ function isoAt(offsetSeconds: number): string { · 72 unmodified lines
129141
130142 function createBaseServerConfig(): ServerConfig {
131143 return {
144+ environment: {
145+ environmentId: EnvironmentId.makeUnsafe("environment-local"),
146+ label: "Local environment",
147+ platform: { os: "darwin" as const, arch: "arm64" as const },
148+ serverVersion: "0.0.0-test",
149+ capabilities: { repositoryIdentity: true },
150+ },
132151 cwd: "/repo/project",
133152 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
134153 keybindings: [],
@@ -314,6 +333,13 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { · 179 unmodified lines
314333 snapshot,
315334 serverConfig: createBaseServerConfig(),
316335 welcome: {
336+ environment: {
337+ environmentId: EnvironmentId.makeUnsafe("environment-local"),
338+ label: "Local environment",
339+ platform: { os: "darwin" as const, arch: "arm64" as const },
340+ serverVersion: "0.0.0-test",
341+ capabilities: { repositoryIdentity: true },
342+ },
317343 cwd: "/repo/project",
318344 projectName: "Project",
319345 bootstrapProjectId: PROJECT_ID,
@@ -396,6 +422,33 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest · 76 unmodified lines
396422 };
397423 }
398424
425+function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent {
426+ return {
427+ sequence,
428+ eventId: EventId.makeUnsafe(`event-thread-session-set-${sequence}`),
429+ aggregateKind: "thread",
430+ aggregateId: threadId,
431+ occurredAt: NOW_ISO,
432+ commandId: null,
433+ causationEventId: null,
434+ correlationId: null,
435+ metadata: {},
436+ type: "thread.session-set",
437+ payload: {
438+ threadId,
439+ session: {
440+ threadId,
441+ status: "running",
442+ providerName: "codex",
443+ runtimeMode: "full-access",
444+ activeTurnId: `turn-${threadId}` as TurnId,
445+ lastError: null,
446+ updatedAt: NOW_ISO,
447+ },
448+ },
449+ };
450+}
451+
399452 function sendOrchestrationDomainEvent(event: OrchestrationEvent): void {
400453 rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event);
401454 }
@@ -419,25 +472,72 @@ async function waitForWsClient(): Promise<void> { · 17 unmodified lines
419472 );
420473 }
421474
475+function threadRefFor(threadId: ThreadId) {
476+ return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);
477+}
478+
479+function threadKeyFor(threadId: ThreadId): string {
480+ return scopedThreadKey(threadRefFor(threadId));
481+}
482+
483+function composerDraftFor(target: string) {
484+ const { draftsByThreadKey } = useComposerDraftStore.getState();
485+ return draftsByThreadKey[target] ?? draftsByThreadKey[threadKeyFor(target as ThreadId)];
486+}
487+
488+function draftIdFromPath(pathname: string) {
489+ const segments = pathname.split("/");
490+ const draftId = segments[segments.length - 1];
491+ if (!draftId) {
492+ throw new Error(`Expected thread path, received "${pathname}".`);
493+ }
494+ return DraftId.makeUnsafe(draftId);
495+}
496+
497+function draftThreadIdFor(draftId: ReturnType<typeof draftIdFromPath>): ThreadId {
498+ const draftSession = useComposerDraftStore.getState().getDraftSession(draftId);
499+ if (!draftSession) {
500+ throw new Error(`Expected draft session for "${draftId}".`);
501+ }
502+ return draftSession.threadId;
503+}
504+
505+function serverThreadPath(threadId: ThreadId): string {
506+ return `/${LOCAL_ENVIRONMENT_ID}/${threadId}`;
507+}
508+
422509 async function waitForAppBootstrap(): Promise<void> {
423510 await vi.waitFor(
424511 () => {
425512 expect(getServerConfig()).not.toBeNull();
426− expect(useStore.getState().bootstrapComplete).toBe(true);
513+ expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true);
427514 },
428515 { timeout: 8_000, interval: 16 },
429516 );
430517 }
431518
432−async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
519+async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
433520 await waitForWsClient();
434521 fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId);
435522 sendOrchestrationDomainEvent(
436523 createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence),
437524 );
525+}
526+
527+async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
528+ sendOrchestrationDomainEvent(
529+ createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1),
530+ );
531+}
532+
533+async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
534+ await materializePromotedDraftThreadViaDomainEvent(threadId);
535+ await startPromotedServerThreadViaDomainEvent(threadId);
438536 await vi.waitFor(
439537 () => {
440− expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined();
538+ expect(useComposerDraftStore.getState().draftThreadsByThreadKey[threadKeyFor(threadId)]).toBe(
539+ undefined,
540+ );
441541 },
442542 { timeout: 8_000, interval: 16 },
443543 );
@@ -468,9 +568,12 @@ function withProjectScripts( · 24 unmodified lines
468568
469569 function setDraftThreadWithoutWorktree(): void {
470570 useComposerDraftStore.setState({
471− draftThreadsByThreadId: {
472− [THREAD_ID]: {
571+ draftThreadsByThreadKey: {
572+ [THREAD_KEY]: {
573+ threadId: THREAD_ID,
574+ environmentId: LOCAL_ENVIRONMENT_ID,
473575 projectId: PROJECT_ID,
576+ logicalProjectKey: PROJECT_DRAFT_KEY,
474577 createdAt: NOW_ISO,
475578 runtimeMode: "full-access",
476579 interactionMode: "default",
@@ -479,8 +582,8 @@ function setDraftThreadWithoutWorktree(): void { · 2 unmodified lines
479582 envMode: "local",
480583 },
481584 },
482− projectDraftThreadIdByProjectId: {
483− [PROJECT_ID]: THREAD_ID,
585+ logicalProjectDraftThreadKeyByLogicalProjectKey: {
586+ [PROJECT_DRAFT_KEY]: THREAD_KEY,
484587 },
485588 });
486589 }
@@ -542,51 +645,12 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { · 55 unmodified lines
542645 };
543646 }
544647
545−function createSnapshotWithPendingUserInput(options?: {
546− questions?: ReadonlyArray<UserInputQuestion>;
547−}): OrchestrationReadModel {
648+function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
548649 const snapshot = createSnapshotForTargetUser({
549650 targetMessageId: "msg-user-pending-input-target" as MessageId,
550651 targetText: "question thread",
551652 });
552653
553− const questions =
554− options?.questions ??
555− ([
556− {
557− id: "scope",
558− header: "Scope",
559− question: "What should this change cover?",
560− options: [
561− {
562− label: "Tight",
563− description: "Touch only the footer layout logic.",
564− },
565− {
566− label: "Broad",
567− description: "Also adjust the related composer controls.",
568− },
569− ],
570− multiSelect: false,
571− },
572− {
573− id: "risk",
574− header: "Risk",
575− question: "How aggressive should the imaginary plan be?",
576− options: [
577− {
578− label: "Conservative",
579− description: "Favor reliability and low-risk changes.",
580− },
581− {
582− label: "Balanced",
583− description: "Mix quick wins with one structural improvement.",
584− },
585− ],
586− multiSelect: false,
587− },
588− ] satisfies ReadonlyArray<UserInputQuestion>);
589−
590654 return {
591655 ...snapshot,
592656 threads: snapshot.threads.map((thread) =>
@@ -601,7 +665,38 @@ function createSnapshotWithPendingUserInput(options?: { · 8 unmodified lines
601665 summary: "User input requested",
602666 payload: {
603667 requestId: "req-browser-user-input",
604− questions,
668+ questions: [
669+ {
670+ id: "scope",
671+ header: "Scope",
672+ question: "What should this change cover?",
673+ options: [
674+ {
675+ label: "Tight",
676+ description: "Touch only the footer layout logic.",
677+ },
678+ {
679+ label: "Broad",
680+ description: "Also adjust the related composer controls.",
681+ },
682+ ],
683+ },
684+ {
685+ id: "risk",
686+ header: "Risk",
687+ question: "How aggressive should the imaginary plan be?",
688+ options: [
689+ {
690+ label: "Conservative",
691+ description: "Favor reliability and low-risk changes.",
692+ },
693+ {
694+ label: "Balanced",
695+ description: "Mix quick wins with one structural improvement.",
696+ },
697+ ],
698+ },
699+ ],
605700 },
606701 turnId: null,
607702 sequence: 1,
@@ -1057,7 +1152,7 @@ async function mountChatView(options: { · 449 unmodified lines
10571152
10581153 const router = getRouter(
10591154 createMemoryHistory({
1060− initialEntries: [`/${THREAD_ID}`],
1155+ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`],
10611156 }),
10621157 );
10631158
@@ -1162,42 +1257,32 @@ describe("ChatView timeline estimator parity (full app)", () => { · 98 unmodified lines
11621257 return [];
11631258 },
11641259 });
1165− await __resetNativeApiForTests();
1260+ await __resetLocalApiForTests();
11661261 await setViewport(DEFAULT_VIEWPORT);
11671262 localStorage.clear();
11681263 document.body.innerHTML = "";
11691264 wsRequests.length = 0;
11701265 customWsRpcResolver = null;
11711266 useComposerDraftStore.setState({
1172− draftsByThreadId: {},
1173− draftThreadsByThreadId: {},
1174− projectDraftThreadIdByProjectId: {},
1267+ draftsByThreadKey: {},
1268+ draftThreadsByThreadKey: {},
1269+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
11751270 stickyModelSelectionByProvider: {},
11761271 stickyActiveProvider: null,
11771272 });
11781273 useStore.setState({
1179− projectIds: [],
1180− projectById: {},
1181− threadIds: [],
1182− threadIdsByProjectId: {},
1183− threadShellById: {},
1184− threadSessionById: {},
1185− threadTurnStateById: {},
1186− messageIdsByThreadId: {},
1187− messageByThreadId: {},
1188− activityIdsByThreadId: {},
1189− activityByThreadId: {},
1190− proposedPlanIdsByThreadId: {},
1191− proposedPlanByThreadId: {},
1192− turnDiffIdsByThreadId: {},
1193− turnDiffSummaryByThreadId: {},
1194− sidebarThreadSummaryById: {},
1195− bootstrapComplete: false,
1274+ activeEnvironmentId: null,
1275+ environmentStateById: {},
1276+ });
1277+ useUiStateStore.setState({
1278+ projectExpandedById: {},
1279+ projectOrder: [],
1280+ threadLastVisitedAtById: {},
11961281 });
11971282 useTerminalStateStore.persist.clearStorage();
11981283 useTerminalStateStore.setState({
1199− terminalStateByThreadId: {},
1200− terminalLaunchContextByThreadId: {},
1284+ terminalStateByThreadKey: {},
1285+ terminalLaunchContextByThreadKey: {},
12011286 terminalEventEntriesByKey: {},
12021287 nextTerminalEventId: 1,
12031288 });
@@ -1241,6 +1326,35 @@ describe("ChatView timeline estimator parity (full app)", () => { · 37 unmodified lines
12411326 },
12421327 );
12431328
1329+ it("re-expands the bootstrap project using its scoped key", async () => {
1330+ useUiStateStore.setState({
1331+ projectExpandedById: {
1332+ [PROJECT_KEY]: false,
1333+ },
1334+ projectOrder: [PROJECT_KEY],
1335+ threadLastVisitedAtById: {},
1336+ });
1337+
1338+ const mounted = await mountChatView({
1339+ viewport: DEFAULT_VIEWPORT,
1340+ snapshot: createSnapshotForTargetUser({
1341+ targetMessageId: "msg-user-bootstrap-project-expand" as MessageId,
1342+ targetText: "bootstrap project expand",
1343+ }),
1344+ });
1345+
1346+ try {
1347+ await vi.waitFor(
1348+ () => {
1349+ expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true);
1350+ },
1351+ { timeout: 8_000, interval: 16 },
1352+ );
1353+ } finally {
1354+ await mounted.cleanup();
1355+ }
1356+ });
1357+
12441358 it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => {
12451359 const userText = "x".repeat(3_200);
12461360 const targetMessageId = "msg-user-target-resize" as MessageId;
@@ -1438,8 +1552,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 191 unmodified lines
14381552 }
14391553
14401554 useTerminalStateStore.setState({
1441− terminalStateByThreadId: {
1442− [THREAD_ID]: {
1555+ terminalStateByThreadKey: {
1556+ [THREAD_KEY]: {
14431557 terminalOpen: true,
14441558 terminalHeight: 280,
14451559 terminalIds: ["default"],
@@ -1449,8 +1563,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 3 unmodified lines
14491563 activeTerminalGroupId: "group-default",
14501564 },
14511565 },
1452− terminalLaunchContextByThreadId: {
1453− [THREAD_ID]: {
1566+ terminalLaunchContextByThreadKey: {
1567+ [THREAD_KEY]: {
14541568 cwd: "/repo/project",
14551569 worktreePath: null,
14561570 },
@@ -1696,9 +1810,12 @@ describe("ChatView timeline estimator parity (full app)", () => { · 239 unmodified lines
16961810
16971811 it("runs project scripts from local draft threads at the project cwd", async () => {
16981812 useComposerDraftStore.setState({
1699− draftThreadsByThreadId: {
1700− [THREAD_ID]: {
1813+ draftThreadsByThreadKey: {
1814+ [THREAD_KEY]: {
1815+ threadId: THREAD_ID,
1816+ environmentId: LOCAL_ENVIRONMENT_ID,
17011817 projectId: PROJECT_ID,
1818+ logicalProjectKey: PROJECT_DRAFT_KEY,
17021819 createdAt: NOW_ISO,
17031820 runtimeMode: "full-access",
17041821 interactionMode: "default",
@@ -1707,8 +1824,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines
17071824 envMode: "local",
17081825 },
17091826 },
1710− projectDraftThreadIdByProjectId: {
1711− [PROJECT_ID]: THREAD_ID,
1827+ logicalProjectDraftThreadKeyByLogicalProjectKey: {
1828+ [PROJECT_DRAFT_KEY]: THREAD_KEY,
17121829 },
17131830 });
17141831
@@ -1772,9 +1889,12 @@ describe("ChatView timeline estimator parity (full app)", () => { · 57 unmodified lines
17721889
17731890 it("runs project scripts from worktree draft threads at the worktree cwd", async () => {
17741891 useComposerDraftStore.setState({
1775− draftThreadsByThreadId: {
1776− [THREAD_ID]: {
1892+ draftThreadsByThreadKey: {
1893+ [THREAD_KEY]: {
1894+ threadId: THREAD_ID,
1895+ environmentId: LOCAL_ENVIRONMENT_ID,
17771896 projectId: PROJECT_ID,
1897+ logicalProjectKey: PROJECT_DRAFT_KEY,
17781898 createdAt: NOW_ISO,
17791899 runtimeMode: "full-access",
17801900 interactionMode: "default",
@@ -1783,8 +1903,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines
17831903 envMode: "worktree",
17841904 },
17851905 },
1786− projectDraftThreadIdByProjectId: {
1787− [PROJECT_ID]: THREAD_ID,
1906+ logicalProjectDraftThreadKeyByLogicalProjectKey: {
1907+ [PROJECT_DRAFT_KEY]: THREAD_KEY,
17881908 },
17891909 });
17901910
@@ -1835,9 +1955,12 @@ describe("ChatView timeline estimator parity (full app)", () => { · 44 unmodified lines
18351955
18361956 it("lets the server own setup after preparing a pull request worktree thread", async () => {
18371957 useComposerDraftStore.setState({
1838− draftThreadsByThreadId: {
1839− [THREAD_ID]: {
1958+ draftThreadsByThreadKey: {
1959+ [THREAD_KEY]: {
1960+ threadId: THREAD_ID,
1961+ environmentId: LOCAL_ENVIRONMENT_ID,
18401962 projectId: PROJECT_ID,
1963+ logicalProjectKey: PROJECT_DRAFT_KEY,
18411964 createdAt: NOW_ISO,
18421965 runtimeMode: "full-access",
18431966 interactionMode: "default",
@@ -1846,8 +1969,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines
18461969 envMode: "local",
18471970 },
18481971 },
1849− projectDraftThreadIdByProjectId: {
1850− [PROJECT_ID]: THREAD_ID,
1972+ logicalProjectDraftThreadKeyByLogicalProjectKey: {
1973+ [PROJECT_DRAFT_KEY]: THREAD_KEY,
18511974 },
18521975 });
18531976
@@ -1957,12 +2080,15 @@ describe("ChatView timeline estimator parity (full app)", () => { · 103 unmodified lines
19572080
19582081 it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => {
19592082 useTerminalStateStore.setState({
1960− terminalStateByThreadId: {},
2083+ terminalStateByThreadKey: {},
19612084 });
19622085 useComposerDraftStore.setState({
1963− draftThreadsByThreadId: {
1964− [THREAD_ID]: {
2086+ draftThreadsByThreadKey: {
2087+ [THREAD_KEY]: {
2088+ threadId: THREAD_ID,
2089+ environmentId: LOCAL_ENVIRONMENT_ID,
19652090 projectId: PROJECT_ID,
2091+ logicalProjectKey: PROJECT_DRAFT_KEY,
19662092 createdAt: NOW_ISO,
19672093 runtimeMode: "full-access",
19682094 interactionMode: "default",
@@ -1971,8 +2097,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines
19712097 envMode: "worktree",
19722098 },
19732099 },
1974− projectDraftThreadIdByProjectId: {
1975− [PROJECT_ID]: THREAD_ID,
2100+ logicalProjectDraftThreadKeyByLogicalProjectKey: {
2101+ [PROJECT_DRAFT_KEY]: THREAD_KEY,
19762102 },
19772103 });
19782104
@@ -1998,7 +2124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 19 unmodified lines
19982124 });
19992125
20002126 try {
2001− useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it");
2127+ useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
20022128 await waitForLayout();
20032129
20042130 const sendButton = await waitForSendButton();
@@ -2057,12 +2183,15 @@ describe("ChatView timeline estimator parity (full app)", () => { · 52 unmodified lines
20572183
20582184 it("shows the send state once bootstrap dispatch is in flight", async () => {
20592185 useTerminalStateStore.setState({
2060− terminalStateByThreadId: {},
2186+ terminalStateByThreadKey: {},
20612187 });
20622188 useComposerDraftStore.setState({
2063− draftThreadsByThreadId: {
2064− [THREAD_ID]: {
2189+ draftThreadsByThreadKey: {
2190+ [THREAD_KEY]: {
2191+ threadId: THREAD_ID,
2192+ environmentId: LOCAL_ENVIRONMENT_ID,
20652193 projectId: PROJECT_ID,
2194+ logicalProjectKey: PROJECT_DRAFT_KEY,
20662195 createdAt: NOW_ISO,
20672196 runtimeMode: "full-access",
20682197 interactionMode: "default",
@@ -2071,8 +2200,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines
20712200 envMode: "worktree",
20722201 },
20732202 },
2074− projectDraftThreadIdByProjectId: {
2075− [PROJECT_ID]: THREAD_ID,
2203+ logicalProjectDraftThreadKeyByLogicalProjectKey: {
2204+ [PROJECT_DRAFT_KEY]: THREAD_KEY,
20762205 },
20772206 });
20782207
@@ -2101,7 +2230,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 22 unmodified lines
21012230 });
21022231
21032232 try {
2104− useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it");
2233+ useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
21052234 await waitForLayout();
21062235
21072236 const sendButton = await waitForSendButton();
@@ -2193,7 +2322,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 85 unmodified lines
21932322 const removedLabel = "Terminal 1 lines 1-2";
21942323 const addedLabel = "Terminal 2 lines 9-10";
21952324 useComposerDraftStore.getState().addTerminalContext(
2196− THREAD_ID,
2325+ THREAD_REF,
21972326 createTerminalContext({
21982327 id: "ctx-removed",
21992328 terminalLabel: "Terminal 1",
@@ -2220,21 +2349,21 @@ describe("ChatView timeline estimator parity (full app)", () => { · 20 unmodified lines
22202349 );
22212350
22222351 const store = useComposerDraftStore.getState();
2223− const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? "";
2352+ const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? "";
22242353 const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0);
2225− store.setPrompt(THREAD_ID, nextPrompt.prompt);
2226− store.removeTerminalContext(THREAD_ID, "ctx-removed");
2354+ store.setPrompt(THREAD_REF, nextPrompt.prompt);
2355+ store.removeTerminalContext(THREAD_REF, "ctx-removed");
22272356
22282357 await vi.waitFor(
22292358 () => {
2230− expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined();
2359+ expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined();
22312360 expect(document.body.textContent).not.toContain(removedLabel);
22322361 },
22332362 { timeout: 8_000, interval: 16 },
22342363 );
22352364
22362365 useComposerDraftStore.getState().addTerminalContext(
2237− THREAD_ID,
2366+ THREAD_REF,
22382367 createTerminalContext({
22392368 id: "ctx-added",
22402369 terminalLabel: "Terminal 2",
@@ -2246,7 +2375,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 5 unmodified lines
22462375
22472376 await vi.waitFor(
22482377 () => {
2249− const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID];
2378+ const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY];
22502379 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]);
22512380 expect(document.body.textContent).toContain(addedLabel);
22522381 expect(document.body.textContent).not.toContain(removedLabel);
@@ -2261,7 +2390,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 8 unmodified lines
22612390 it("disables send when the composer only contains an expired terminal pill", async () => {
22622391 const expiredLabel = "Terminal 1 line 4";
22632392 useComposerDraftStore.getState().addTerminalContext(
2264− THREAD_ID,
2393+ THREAD_REF,
22652394 createTerminalContext({
22662395 id: "ctx-expired-only",
22672396 terminalLabel: "Terminal 1",
@@ -2297,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 29 unmodified lines
22972426 it("warns when sending text while omitting expired terminal pills", async () => {
22982427 const expiredLabel = "Terminal 1 line 4";
22992428 useComposerDraftStore.getState().addTerminalContext(
2300− THREAD_ID,
2429+ THREAD_REF,
23012430 createTerminalContext({
23022431 id: "ctx-expired-send-warning",
23032432 terminalLabel: "Terminal 1",
@@ -2308,7 +2437,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 4 unmodified lines
23082437 );
23092438 useComposerDraftStore
23102439 .getState()
2311− .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`);
2440+ .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`);
23122441
23132442 const mounted = await mountChatView({
23142443 viewport: DEFAULT_VIEWPORT,
@@ -2448,7 +2577,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 133 unmodified lines
24482577 }
24492578 });
24502579
2451− it("keeps the new thread selected after clicking the new-thread button", async () => {
2580+ it("canonicalizes promoted draft threads to the server thread route", async () => {
24522581 const mounted = await mountChatView({
24532582 viewport: DEFAULT_VIEWPORT,
24542583 snapshot: createSnapshotForTargetUser({
@@ -2470,21 +2599,34 @@ describe("ChatView timeline estimator parity (full app)", () => { · 15 unmodified lines
24702599 (path) => UUID_ROUTE_RE.test(path),
24712600 "Route should have changed to a new draft thread UUID.",
24722601 );
2473− const newThreadId = newThreadPath.slice(1) as ThreadId;
2602+ const newDraftId = draftIdFromPath(newThreadPath);
2603+ const newThreadId = draftThreadIdFor(newDraftId);
24742604
24752605 // The composer editor should be present for the new draft thread.
24762606 await waitForComposerEditor();
24772607
2478− // Simulate the steady-state promotion path: the server emits
2479− // `thread.created`, the client materializes the thread incrementally,
2480− // and the draft is cleared by live batch effects.
2481− await promoteDraftThreadViaDomainEvent(newThreadId);
2608+ // `thread.created` should only mark the draft as promoting; it should
2609+ // not navigate away until the server thread has actual runtime state.
2610+ await materializePromotedDraftThreadViaDomainEvent(newThreadId);
2611+ expect(mounted.router.state.location.pathname).toBe(newThreadPath);
2612+ await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument();
2613+
2614+ // Once the server thread starts, the route should canonicalize.
2615+ await startPromotedServerThreadViaDomainEvent(newThreadId);
2616+ await vi.waitFor(
2617+ () => {
2618+ expect(useComposerDraftStore.getState().draftThreadsByThreadKey[newDraftId]).toBe(
2619+ undefined,
2620+ );
2621+ },
2622+ { timeout: 8_000, interval: 16 },
2623+ );
24822624
2483− // The route should still be on the new thread — not redirected away.
2625+ // The route should switch to the canonical server thread path.
24842626 await waitForURL(
24852627 mounted.router,
2486− (path) => path === newThreadPath,
2487− "New thread should remain selected after server thread promotion clears the draft.",
2628+ (path) => path === serverThreadPath(newThreadId),
2629+ "Promoted drafts should canonicalize to the server thread route.",
24882630 );
24892631
24902632 // The empty thread view and composer should still be visible.
@@ -2497,6 +2639,48 @@ describe("ChatView timeline estimator parity (full app)", () => { · 6 unmodified lines
24972639 }
24982640 });
24992641
2642+ it("canonicalizes stale promoted draft routes to the server thread route", async () => {
2643+ const mounted = await mountChatView({
2644+ viewport: DEFAULT_VIEWPORT,
2645+ snapshot: createSnapshotForTargetUser({
2646+ targetMessageId: "msg-user-draft-hydration-race-test" as MessageId,
2647+ targetText: "draft hydration race test",
2648+ }),
2649+ });
2650+
2651+ try {
2652+ const newThreadButton = page.getByTestId("new-thread-button");
2653+ await expect.element(newThreadButton).toBeInTheDocument();
2654+
2655+ await newThreadButton.click();
2656+
2657+ const newThreadPath = await waitForURL(
2658+ mounted.router,
2659+ (path) => UUID_ROUTE_RE.test(path),
2660+ "Route should have changed to a new draft thread UUID.",
2661+ );
2662+ const newDraftId = draftIdFromPath(newThreadPath);
2663+ const newThreadId = draftThreadIdFor(newDraftId);
2664+
2665+ await promoteDraftThreadViaDomainEvent(newThreadId);
2666+
2667+ await mounted.router.navigate({
2668+ to: "/draft/$draftId",
2669+ params: { draftId: newDraftId },
2670+ });
2671+
2672+ await waitForURL(
2673+ mounted.router,
2674+ (path) => path === serverThreadPath(newThreadId),
2675+ "Stale promoted draft routes should canonicalize to the server thread path.",
2676+ );
2677+
2678+ await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument();
2679+ } finally {
2680+ await mounted.cleanup();
2681+ }
2682+ });
2683+
25002684 it("snapshots sticky codex settings into a new draft thread", async () => {
25012685 useComposerDraftStore.setState({
25022686 stickyModelSelectionByProvider: {
@@ -2531,9 +2715,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 28 unmodified lines
25312715 (path) => UUID_ROUTE_RE.test(path),
25322716 "Route should have changed to a new draft thread UUID.",
25332717 );
2534− const newThreadId = newThreadPath.slice(1) as ThreadId;
2718+ const newDraftId = draftIdFromPath(newThreadPath);
25352719
2536− expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({
2720+ expect(composerDraftFor(newDraftId)).toMatchObject({
25372721 modelSelectionByProvider: {
25382722 codex: {
25392723 provider: "codex",
@@ -2584,9 +2768,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 44 unmodified lines
25842768 (path) => UUID_ROUTE_RE.test(path),
25852769 "Route should have changed to a new sticky claude draft thread UUID.",
25862770 );
2587− const newThreadId = newThreadPath.slice(1) as ThreadId;
2771+ const newDraftId = draftIdFromPath(newThreadPath);
25882772
2589− expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({
2773+ expect(composerDraftFor(newDraftId)).toMatchObject({
25902774 modelSelectionByProvider: {
25912775 claudeAgent: {
25922776 provider: "claudeAgent",
@@ -2624,9 +2808,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 31 unmodified lines
26242808 (path) => UUID_ROUTE_RE.test(path),
26252809 "Route should have changed to a new draft thread UUID.",
26262810 );
2627− const newThreadId = newThreadPath.slice(1) as ThreadId;
2811+ const newDraftId = draftIdFromPath(newThreadPath);
26282812
2629− expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined();
2813+ expect(composerDraftFor(newDraftId)).toBe(undefined);
26302814 } finally {
26312815 await mounted.cleanup();
26322816 }
@@ -2666,9 +2850,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 33 unmodified lines
26662850 (path) => UUID_ROUTE_RE.test(path),
26672851 "Route should have changed to a sticky draft thread UUID.",
26682852 );
2669− const threadId = threadPath.slice(1) as ThreadId;
2853+ const draftId = draftIdFromPath(threadPath);
26702854
2671− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
2855+ expect(composerDraftFor(draftId)).toMatchObject({
26722856 modelSelectionByProvider: {
26732857 codex: {
26742858 provider: "codex",
@@ -2681,7 +2865,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 6 unmodified lines
26812865 activeProvider: "codex",
26822866 });
26832867
2684− useComposerDraftStore.getState().setModelSelection(threadId, {
2868+ useComposerDraftStore.getState().setModelSelection(draftId, {
26852869 provider: "codex",
26862870 model: "gpt-5.4",
26872871 options: {
@@ -2697,7 +2881,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 9 unmodified lines
26972881 (path) => path === threadPath,
26982882 "New-thread should reuse the existing project draft thread.",
26992883 );
2700− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
2884+ expect(composerDraftFor(draftId)).toMatchObject({
27012885 modelSelectionByProvider: {
27022886 codex: {
27032887 provider: "codex",
@@ -2804,7 +2988,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 100 unmodified lines
28042988 (path) => UUID_ROUTE_RE.test(path),
28052989 "Route should have changed to a promoted draft thread UUID.",
28062990 );
2807− const promotedThreadId = promotedThreadPath.slice(1) as ThreadId;
2991+ const promotedDraftId = draftIdFromPath(promotedThreadPath);
2992+ const promotedThreadId = draftThreadIdFor(promotedDraftId);
28082993
28092994 await promoteDraftThreadViaDomainEvent(promotedThreadId);
28102995
@@ -2925,70 +3110,10 @@ describe("ChatView timeline estimator parity (full app)", () => { · 114 unmodified lines
29253110 }
29263111 });
29273112
2928− it("does not trigger numeric option shortcuts while the composer is focused", async () => {
3113+ it("submits pending user input after the final option selection resolves the draft answers", async () => {
29293114 const mounted = await mountChatView({
2930− viewport: WIDE_FOOTER_VIEWPORT,
3115+ viewport: DEFAULT_VIEWPORT,
29313116 snapshot: createSnapshotWithPendingUserInput(),
2932− });
2933−
2934− try {
2935− const composerEditor = await waitForComposerEditor();
2936− composerEditor.focus();
2937−
2938− const event = new KeyboardEvent("keydown", {
2939− key: "2",
2940− bubbles: true,
2941− cancelable: true,
2942− });
2943− composerEditor.dispatchEvent(event);
2944− await waitForLayout();
2945−
2946− expect(event.defaultPrevented).toBe(false);
2947− expect(document.body.textContent).toContain("What should this change cover?");
2948− expect(document.body.textContent).not.toContain(
2949− "How aggressive should the imaginary plan be?",
2950− );
2951− await waitForButtonByText("Next question");
2952− } finally {
2953− await mounted.cleanup();
2954− }
2955− });
2956−
2957− it("submits multi-select questionnaire answers as arrays", async () => {
2958− const mounted = await mountChatView({
2959− viewport: WIDE_FOOTER_VIEWPORT,
2960− snapshot: createSnapshotWithPendingUserInput({
2961− questions: [
2962− {
2963− id: "scope",
2964− header: "Scope",
2965− question: "Which areas should this change cover?",
2966− options: [
2967− {
2968− label: "Server",
2969− description: "Touch server orchestration.",
2970− },
2971− {
2972− label: "Web",
2973− description: "Touch the browser UI.",
2974− },
2975− ],
2976− multiSelect: true,
2977− },
2978− {
2979− id: "risk",
2980− header: "Risk",
2981− question: "How aggressive should the imaginary plan be?",
2982− options: [
2983− {
2984− label: "Balanced",
2985− description: "Mix quick wins with one structural improvement.",
2986− },
2987− ],
2988− multiSelect: false,
2989− },
2990− ],
2991− }),
29923117 resolveRpc: (body) => {
29933118 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
29943119 return {
@@ -3000,37 +3125,11 @@ describe("ChatView timeline estimator parity (full app)", () => { · 5 unmodified lines
30003125 });
30013126
30023127 try {
3003− const serverOption = await waitForButtonContainingText("Server");
3004− serverOption.click();
3005− await waitForLayout();
3006−
3007− expect(document.body.textContent).toContain("Which areas should this change cover?");
3008−
3009− const webOption = await waitForButtonContainingText("Web");
3010− webOption.click();
3011− await waitForLayout();
3012−
3013− expect(document.body.textContent).toContain("Which areas should this change cover?");
3014−
3015− const nextButton = await waitForButtonByText("Next question");
3016− expect(nextButton.disabled).toBe(false);
3017− nextButton.click();
3018−
3019− await vi.waitFor(
3020− () => {
3021− expect(document.body.textContent).toContain(
3022− "How aggressive should the imaginary plan be?",
3023− );
3024− },
3025− { timeout: 8_000, interval: 16 },
3026− );
3027−
3028− const balancedOption = await waitForButtonContainingText("Balanced");
3029− balancedOption.click();
3128+ const firstOption = await waitForButtonContainingText("Tight");
3129+ firstOption.click();
30303130
3031− const submitButton = await waitForButtonByText("Submit answers");
3032− expect(submitButton.disabled).toBe(false);
3033− submitButton.click();
3131+ const finalOption = await waitForButtonContainingText("Conservative");
3132+ finalOption.click();
30343133
30353134 await vi.waitFor(
30363135 () => {
@@ -3042,6 +3141,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 5 unmodified lines
30423141 | {
30433142 _tag: string;
30443143 type?: string;
3144+ requestId?: string;
30453145 answers?: Record<string, unknown>;
30463146 }
30473147 | undefined;
@@ -3049,9 +3149,10 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1 unmodified line
30493149 expect(dispatchRequest).toMatchObject({
30503150 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
30513151 type: "thread.user-input.respond",
3152+ requestId: "req-browser-user-input",
30523153 answers: {
3053− scope: ["Server", "Web"],
3054− risk: "Balanced",
3154+ scope: "Tight",
3155+ risk: "Conservative",
30553156 },
30563157 });
30573158 },
apps/web/src/components/ChatView.logic.test.ts+57−7
@@ -1,6 +1,7 @@
1−import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
1+import { scopeThreadRef } from "@t3tools/client-runtime";
2+import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
23 import { afterEach, describe, expect, it, vi } from "vitest";
3−import { useStore } from "../store";
4+import { type EnvironmentState, useStore } from "../store";
45 import { type Thread } from "../types";
56
67 import {
@@ -10,9 +11,12 @@ import { · 3 unmodified lines
1011 deriveComposerSendState,
1112 hasServerAcknowledgedLocalDispatch,
1213 reconcileMountedTerminalThreadIds,
14+ shouldWriteThreadErrorToCurrentServerThread,
1315 waitForStartedServerThread,
1416 } from "./ChatView.logic";
1517
18+const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
19+
1620 describe("deriveComposerSendState", () => {
1721 it("treats expired terminal pills as non-sendable content", () => {
1822 const state = deriveComposerSendState({
@@ -170,6 +174,36 @@ describe("reconcileMountedTerminalThreadIds", () => { · 151 unmodified lines
170174 });
171175 });
172176
177+describe("shouldWriteThreadErrorToCurrentServerThread", () => {
178+ it("routes errors to the active server thread when route and target match", () => {
179+ const threadId = ThreadId.makeUnsafe("thread-1");
180+ const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId);
181+
182+ expect(
183+ shouldWriteThreadErrorToCurrentServerThread({
184+ serverThread: {
185+ environmentId: localEnvironmentId,
186+ id: threadId,
187+ },
188+ routeThreadRef,
189+ targetThreadId: threadId,
190+ }),
191+ ).toBe(true);
192+ });
193+
194+ it("does not route draft-thread errors into server-backed state", () => {
195+ const threadId = ThreadId.makeUnsafe("thread-1");
196+
197+ expect(
198+ shouldWriteThreadErrorToCurrentServerThread({
199+ serverThread: undefined,
200+ routeThreadRef: scopeThreadRef(localEnvironmentId, threadId),
201+ targetThreadId: threadId,
202+ }),
203+ ).toBe(false);
204+ });
205+});
206+
173207 const makeThread = (input?: {
174208 id?: ThreadId;
175209 latestTurn?: {
@@ -181,6 +215,7 @@ const makeThread = (input?: { · 5 unmodified lines
181215 } | null;
182216 }): Thread => ({
183217 id: input?.id ?? ThreadId.makeUnsafe("thread-1"),
218+ environmentId: localEnvironmentId,
184219 codexThreadId: null,
185220 projectId: ProjectId.makeUnsafe("project-1"),
186221 title: "Thread",
@@ -208,11 +243,12 @@ const makeThread = (input?: { · 21 unmodified lines
208243
209244 function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) {
210245 const projectId = ProjectId.makeUnsafe("project-1");
211− useStore.setState({
246+ const environmentState: EnvironmentState = {
212247 projectIds: [projectId],
213248 projectById: {
214249 [projectId]: {
215250 id: projectId,
251+ environmentId: localEnvironmentId,
216252 name: "Project",
217253 cwd: "/tmp/project",
218254 defaultModelSelection: {
@@ -233,6 +269,7 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) · 14 unmodified lines
233269 thread.id,
234270 {
235271 id: thread.id,
272+ environmentId: thread.environmentId,
236273 codexThreadId: thread.codexThreadId,
237274 projectId: thread.projectId,
238275 title: thread.title,
@@ -301,6 +338,12 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) · 62 unmodified lines
301338 ),
302339 sidebarThreadSummaryById: {},
303340 bootstrapComplete: true,
341+ };
342+ useStore.setState({
343+ activeEnvironmentId: localEnvironmentId,
344+ environmentStateById: {
345+ [localEnvironmentId]: environmentState,
346+ },
304347 });
305348 }
306349
@@ -326,14 +369,16 @@ describe("waitForStartedServerThread", () => { · 19 unmodified lines
326369 }),
327370 ]);
328371
329− await expect(waitForStartedServerThread(threadId)).resolves.toBe(true);
372+ await expect(
373+ waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)),
374+ ).resolves.toBe(true);
330375 });
331376
332377 it("waits for the thread to start via subscription updates", async () => {
333378 const threadId = ThreadId.makeUnsafe("thread-wait");
334379 setStoreThreads([makeThread({ id: threadId })]);
335380
336− const promise = waitForStartedServerThread(threadId, 500);
381+ const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500);
337382
338383 setStoreThreads([
339384 makeThread({
@@ -376,7 +421,9 @@ describe("waitForStartedServerThread", () => { · 36 unmodified lines
376421 return originalSubscribe(listener);
377422 });
378423
379− await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true);
424+ await expect(
425+ waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500),
426+ ).resolves.toBe(true);
380427 });
381428
382429 it("returns false after the timeout when the thread never starts", async () => {
@@ -384,7 +431,7 @@ describe("waitForStartedServerThread", () => { · 1 unmodified line
384431
385432 const threadId = ThreadId.makeUnsafe("thread-timeout");
386433 setStoreThreads([makeThread({ id: threadId })]);
387− const promise = waitForStartedServerThread(threadId, 500);
434+ const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500);
388435
389436 await vi.advanceTimersByTimeAsync(500);
390437
@@ -414,6 +461,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 23 unmodified lines
414461 it("does not clear local dispatch before server state changes", () => {
415462 const localDispatch = createLocalDispatchSnapshot({
416463 id: ThreadId.makeUnsafe("thread-1"),
464+ environmentId: localEnvironmentId,
417465 codexThreadId: null,
418466 projectId,
419467 title: "Thread",
@@ -450,6 +498,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 30 unmodified lines
450498 it("clears local dispatch when a new turn is already settled", () => {
451499 const localDispatch = createLocalDispatchSnapshot({
452500 id: ThreadId.makeUnsafe("thread-1"),
501+ environmentId: localEnvironmentId,
453502 codexThreadId: null,
454503 projectId,
455504 title: "Thread",
@@ -495,6 +544,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 39 unmodified lines
495544 it("clears local dispatch when the session changes without an observed running phase", () => {
496545 const localDispatch = createLocalDispatchSnapshot({
497546 id: ThreadId.makeUnsafe("thread-1"),
547+ environmentId: localEnvironmentId,
498548 codexThreadId: null,
499549 projectId,
500550 title: "Thread",
apps/web/src/components/ChatView.logic.ts+36−9
@@ -1,9 +1,16 @@
1−import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts";
1+import {
2+ type EnvironmentId,
3+ ProjectId,
4+ type ModelSelection,
5+ type ScopedThreadRef,
6+ type ThreadId,
7+ type TurnId,
8+} from "@t3tools/contracts";
29 import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";
310 import { randomUUID } from "~/lib/utils";
411 import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
512 import { Schema } from "effect";
6−import { selectThreadById, useStore } from "../store";
13+import { selectThreadByRef, useStore } from "../store";
714 import {
815 filterTerminalContextsWithText,
916 stripInlineTerminalContextPlaceholders,
@@ -24,6 +31,7 @@ export function buildLocalDraftThread( · 14 unmodified lines
2431 ): Thread {
2532 return {
2633 id: threadId,
34+ environmentId: draftThread.environmentId,
2735 codexThreadId: null,
2836 projectId: draftThread.projectId,
2937 title: "New thread",
@@ -44,13 +52,32 @@ export function buildLocalDraftThread( · 14 unmodified lines
4452 };
4553 }
4654
55+export function shouldWriteThreadErrorToCurrentServerThread(input: {
56+ serverThread:
57+ | {
58+ environmentId: EnvironmentId;
59+ id: ThreadId;
60+ }
61+ | null
62+ | undefined;
63+ routeThreadRef: ScopedThreadRef;
64+ targetThreadId: ThreadId;
65+}): boolean {
66+ return Boolean(
67+ input.serverThread &&
68+ input.targetThreadId === input.routeThreadRef.threadId &&
69+ input.serverThread.environmentId === input.routeThreadRef.environmentId &&
70+ input.serverThread.id === input.targetThreadId,
71+ );
72+}
73+
4774 export function reconcileMountedTerminalThreadIds(input: {
48− currentThreadIds: ReadonlyArray<ThreadId>;
49− openThreadIds: ReadonlyArray<ThreadId>;
50− activeThreadId: ThreadId | null;
75+ currentThreadIds: ReadonlyArray<string>;
76+ openThreadIds: ReadonlyArray<string>;
77+ activeThreadId: string | null;
5178 activeThreadTerminalOpen: boolean;
5279 maxHiddenThreadCount?: number;
53−}): ThreadId[] {
80+}): string[] {
5481 const openThreadIdSet = new Set(input.openThreadIds);
5582 const hiddenThreadIds = input.currentThreadIds.filter(
5683 (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId),
@@ -199,10 +226,10 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { · 142 unmodified lines
199226 }
200227
201228 export async function waitForStartedServerThread(
202− threadId: ThreadId,
229+ threadRef: ScopedThreadRef,
203230 timeoutMs = 1_000,
204231 ): Promise<boolean> {
205− const getThread = () => selectThreadById(threadId)(useStore.getState());
232+ const getThread = () => selectThreadByRef(useStore.getState(), threadRef);
206233 const thread = getThread();
207234
208235 if (threadHasStarted(thread)) {
@@ -225,7 +252,7 @@ export async function waitForStartedServerThread( · 16 unmodified lines
225252 };
226253
227254 const unsubscribe = useStore.subscribe((state) => {
228− if (!threadHasStarted(selectThreadById(threadId)(state))) {
255+ if (!threadHasStarted(selectThreadByRef(state, threadRef))) {
229256 return;
230257 }
231258 finish(true);
apps/web/src/components/ChatView.tsx+522−254
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 type ApprovalRequestId,
33 DEFAULT_MODEL_BY_PROVIDER,
44 type ClaudeCodeEffort,
5+ type EnvironmentId,
56 type MessageId,
67 type ModelSelection,
78 type ProjectScript,
@@ -12,6 +13,7 @@ import { · 4 unmodified lines
1213 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1314 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1415 type ServerProvider,
16+ type ScopedThreadRef,
1517 type ThreadId,
1618 type TurnId,
1719 type KeybindingCommand,
@@ -20,6 +22,12 @@ import { · 2 unmodified lines
2022 RuntimeMode,
2123 TerminalOpenInput,
2224 } from "@t3tools/contracts";
25+import {
26+ parseScopedThreadKey,
27+ scopedThreadKey,
28+ scopeProjectRef,
29+ scopeThreadRef,
30+} from "@t3tools/client-runtime";
2331 import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
2432 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
2533 import { truncate } from "@t3tools/shared/String";
@@ -27,9 +35,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr · 1 unmodified line
2735 import { useQuery } from "@tanstack/react-query";
2836 import { useDebouncedValue } from "@tanstack/react-pacer";
2937 import { useNavigate, useSearch } from "@tanstack/react-router";
38+import { useShallow } from "zustand/react/shallow";
3039 import { useGitStatus } from "~/lib/gitStatusState";
3140 import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
41+import { readEnvironmentApi } from "../environmentApi";
3242 import { isElectron } from "../env";
43+import { readLocalApi } from "../localApi";
3344 import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
3445 import {
3546 clampCollapsedComposerCursor,
@@ -64,8 +75,8 @@ import { · 28 unmodified lines
6475 togglePendingUserInputOptionSelection,
6576 type PendingUserInputDraftAnswer,
6677 } from "../pendingUserInput";
67−import { useStore } from "../store";
68−import { createThreadSelector } from "../storeSelectors";
78+import { selectThreadsAcrossEnvironments, useStore } from "../store";
79+import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
6980 import { useUiStateStore } from "../uiStateStore";
7081 import {
7182 buildPlanImplementationThreadTitle,
@@ -114,8 +125,7 @@ import { · 42 unmodified lines
114125 projectScriptIdFromCommand,
115126 } from "~/projectScripts";
116127 import { SidebarTrigger } from "./ui/sidebar";
117−import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
118−import { readNativeApi } from "~/nativeApi";
128+import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";
119129 import {
120130 getProviderModelCapabilities,
121131 getProviderModels,
@@ -124,6 +134,8 @@ import { · 2 unmodified lines
124134 import { useSettings } from "../hooks/useSettings";
125135 import { resolveAppModelSelection } from "../modelSelection";
126136 import { isTerminalFocused } from "../lib/terminalFocus";
137+import { deriveLogicalProjectKey } from "../logicalProject";
138+import { buildDraftThreadRouteParams } from "../threadRoutes";
127139 import {
128140 type ComposerImageAttachment,
129141 type DraftThreadEnvMode,
@@ -131,6 +143,7 @@ import { · 1 unmodified line
131143 useComposerDraftStore,
132144 useEffectiveComposerModelState,
133145 useComposerThreadDraft,
146+ type DraftId,
134147 } from "../composerDraftStore";
135148 import {
136149 appendTerminalContextsToPrompt,
@@ -187,6 +200,7 @@ import { · 50 unmodified lines
187200 reconcileMountedTerminalThreadIds,
188201 revokeBlobPreviewUrl,
189202 revokeUserMessagePreviewUrls,
203+ shouldWriteThreadErrorToCurrentServerThread,
190204 threadHasStarted,
191205 waitForStartedServerThread,
192206 } from "./ChatView.logic";
@@ -203,6 +217,7 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES · 10 unmodified lines
203217 const IMAGE_ONLY_BOOTSTRAP_PROMPT =
204218 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
205219 const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
220+const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
206221 const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
207222 const EMPTY_PROVIDERS: ServerProvider[] = [];
208223 const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
@@ -210,26 +225,115 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw · 1 unmodified line
210225 type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
211226
212227 function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] {
213− const threadShellById = useStore((state) => state.threadShellById);
214− const proposedPlanIdsByThreadId = useStore((state) => state.proposedPlanIdsByThreadId);
215− const proposedPlanByThreadId = useStore((state) => state.proposedPlanByThreadId);
228+ return useStore(
229+ useMemo(() => {
230+ let previousThreadIds: readonly ThreadId[] = [];
231+ let previousResult: ThreadPlanCatalogEntry[] = [];
232+ let previousEntries = new Map<
233+ ThreadId,
234+ {
235+ shell: object | null;
236+ proposedPlanIds: readonly string[] | undefined;
237+ proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined;
238+ entry: ThreadPlanCatalogEntry;
239+ }
240+ >();
241+
242+ return (state) => {
243+ const sameThreadIds =
244+ previousThreadIds.length === threadIds.length &&
245+ previousThreadIds.every((id, index) => id === threadIds[index]);
246+ const nextEntries = new Map<
247+ ThreadId,
248+ {
249+ shell: object | null;
250+ proposedPlanIds: readonly string[] | undefined;
251+ proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined;
252+ entry: ThreadPlanCatalogEntry;
253+ }
254+ >();
255+ const nextResult: ThreadPlanCatalogEntry[] = [];
256+ let changed = !sameThreadIds;
257+
258+ for (const threadId of threadIds) {
259+ let shell: object | undefined;
260+ let proposedPlanIds: readonly string[] | undefined;
261+ let proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined;
262+
263+ for (const environmentState of Object.values(state.environmentStateById)) {
264+ const matchedShell = environmentState.threadShellById[threadId];
265+ if (!matchedShell) {
266+ continue;
267+ }
268+ shell = matchedShell;
269+ proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId];
270+ proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as
271+ | Record<string, Thread["proposedPlans"][number]>
272+ | undefined;
273+ break;
274+ }
216275
217− return useMemo(
218− () =>
219− threadIds.flatMap((threadId) => {
220− if (!threadShellById[threadId]) {
221− return [];
276+ if (!shell) {
277+ const previous = previousEntries.get(threadId);
278+ if (
279+ previous &&
280+ previous.shell === null &&
281+ previous.proposedPlanIds === undefined &&
282+ previous.proposedPlansById === undefined
283+ ) {
284+ nextEntries.set(threadId, previous);
285+ continue;
286+ }
287+ changed = true;
288+ nextEntries.set(threadId, {
289+ shell: null,
290+ proposedPlanIds: undefined,
291+ proposedPlansById: undefined,
292+ entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS },
293+ });
294+ continue;
295+ }
296+
297+ const previous = previousEntries.get(threadId);
298+ if (
299+ previous &&
300+ previous.shell === shell &&
301+ previous.proposedPlanIds === proposedPlanIds &&
302+ previous.proposedPlansById === proposedPlansById
303+ ) {
304+ nextEntries.set(threadId, previous);
305+ nextResult.push(previous.entry);
306+ continue;
307+ }
308+
309+ changed = true;
310+ const proposedPlans =
311+ proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById
312+ ? proposedPlanIds.flatMap((planId) => {
313+ const proposedPlan = proposedPlansById?.[planId];
314+ return proposedPlan ? [proposedPlan] : [];
315+ })
316+ : EMPTY_PROPOSED_PLANS;
317+ const entry = { id: threadId, proposedPlans };
318+ nextEntries.set(threadId, {
319+ shell,
320+ proposedPlanIds,
321+ proposedPlansById,
322+ entry,
323+ });
324+ nextResult.push(entry);
222325 }
223326
224− const proposedPlans =
225− proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => {
226− const plan = proposedPlanByThreadId[threadId]?.[planId];
227− return plan ? [plan] : [];
228− }) ?? [];
327+ if (!changed && previousResult.length === nextResult.length) {
328+ return previousResult;
329+ }
229330
230− return [{ id: threadId, proposedPlans }];
231− }),
232− [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById],
331+ previousThreadIds = threadIds;
332+ previousEntries = nextEntries;
333+ previousResult = nextResult;
334+ return nextResult;
335+ };
336+ }, [threadIds]),
233337 );
234338 }
235339
@@ -278,9 +382,19 @@ const terminalContextIdListsEqual = ( · 42 unmodified lines
278382 ): boolean =>
279383 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
280384
281−interface ChatViewProps {
282− threadId: ThreadId;
283−}
385+type ChatViewProps =
386+ | {
387+ environmentId: EnvironmentId;
388+ threadId: ThreadId;
389+ routeKind: "server";
390+ draftId?: never;
391+ }
392+ | {
393+ environmentId: EnvironmentId;
394+ threadId: ThreadId;
395+ routeKind: "draft";
396+ draftId: DraftId;
397+ };
284398
285399 interface TerminalLaunchContext {
286400 threadId: ThreadId;
@@ -358,6 +472,7 @@ function useLocalDispatchState(input: { · 71 unmodified lines
358472 }
359473
360474 interface PersistentThreadTerminalDrawerProps {
475+ threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
361476 threadId: ThreadId;
362477 visible: boolean;
363478 launchContext: PersistentTerminalLaunchContext | null;
@@ -369,6 +484,7 @@ interface PersistentThreadTerminalDrawerProps { · 5 unmodified lines
369484 }
370485
371486 function PersistentThreadTerminalDrawer({
487+ threadRef,
372488 threadId,
373489 visible,
374490 launchContext,
@@ -378,19 +494,16 @@ function PersistentThreadTerminalDrawer({ · 3 unmodified lines
378494 closeShortcutLabel,
379495 onAddTerminalContext,
380496 }: PersistentThreadTerminalDrawerProps) {
381− const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId]));
382− const draftThread = useComposerDraftStore(
383− (store) => store.draftThreadsByThreadId[threadId] ?? null,
384− );
385− const project = useStore((state) =>
386− serverThread?.projectId
387− ? state.projectById[serverThread.projectId]
388− : draftThread?.projectId
389− ? state.projectById[draftThread.projectId]
390− : undefined,
391− );
497+ const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
498+ const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
499+ const projectRef = serverThread
500+ ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
501+ : draftThread
502+ ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
503+ : null;
504+ const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef]));
392505 const terminalState = useTerminalStateStore((state) =>
393− selectThreadTerminalState(state.terminalStateByThreadId, threadId),
506+ selectThreadTerminalState(state.terminalStateByThreadKey, threadRef),
394507 );
395508 const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight);
396509 const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal);
@@ -436,32 +549,32 @@ function PersistentThreadTerminalDrawer({ · 39 unmodified lines
436549
437550 const setTerminalHeight = useCallback(
438551 (height: number) => {
439− storeSetTerminalHeight(threadId, height);
552+ storeSetTerminalHeight(threadRef, height);
440553 },
441− [storeSetTerminalHeight, threadId],
554+ [storeSetTerminalHeight, threadRef],
442555 );
443556
444557 const splitTerminal = useCallback(() => {
445− storeSplitTerminal(threadId, `terminal-${randomUUID()}`);
558+ storeSplitTerminal(threadRef, `terminal-${randomUUID()}`);
446559 bumpFocusRequestId();
447− }, [bumpFocusRequestId, storeSplitTerminal, threadId]);
560+ }, [bumpFocusRequestId, storeSplitTerminal, threadRef]);
448561
449562 const createNewTerminal = useCallback(() => {
450− storeNewTerminal(threadId, `terminal-${randomUUID()}`);
563+ storeNewTerminal(threadRef, `terminal-${randomUUID()}`);
451564 bumpFocusRequestId();
452− }, [bumpFocusRequestId, storeNewTerminal, threadId]);
565+ }, [bumpFocusRequestId, storeNewTerminal, threadRef]);
453566
454567 const activateTerminal = useCallback(
455568 (terminalId: string) => {
456− storeSetActiveTerminal(threadId, terminalId);
569+ storeSetActiveTerminal(threadRef, terminalId);
457570 bumpFocusRequestId();
458571 },
459− [bumpFocusRequestId, storeSetActiveTerminal, threadId],
572+ [bumpFocusRequestId, storeSetActiveTerminal, threadRef],
460573 );
461574
462575 const closeTerminal = useCallback(
463576 (terminalId: string) => {
464− const api = readNativeApi();
577+ const api = readEnvironmentApi(threadRef.environmentId);
465578 if (!api) return;
466579 const isFinalTerminal = terminalState.terminalIds.length <= 1;
467580 const fallbackExitWrite = () =>
@@ -482,10 +595,10 @@ function PersistentThreadTerminalDrawer({ · 14 unmodified lines
482595 void fallbackExitWrite();
483596 }
484597
485− storeCloseTerminal(threadId, terminalId);
598+ storeCloseTerminal(threadRef, terminalId);
486599 bumpFocusRequestId();
487600 },
488− [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId],
601+ [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef],
489602 );
490603
491604 const handleAddTerminalContext = useCallback(
@@ -505,6 +618,7 @@ function PersistentThreadTerminalDrawer({ · 13 unmodified lines
505618 return (
506619 <div className={visible ? undefined : "hidden"}>
507620 <ThreadTerminalDrawer
621+ threadRef={threadRef}
508622 threadId={threadId}
509623 cwd={cwd}
510624 worktreePath={effectiveWorktreePath}
@@ -530,12 +644,27 @@ function PersistentThreadTerminalDrawer({ · 19 unmodified lines
530644 );
531645 }
532646
533−export default function ChatView({ threadId }: ChatViewProps) {
534− const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId]));
647+export default function ChatView(props: ChatViewProps) {
648+ const { environmentId, threadId, routeKind } = props;
649+ const draftId = routeKind === "draft" ? props.draftId : null;
650+ const routeThreadRef = useMemo(
651+ () => scopeThreadRef(environmentId, threadId),
652+ [environmentId, threadId],
653+ );
654+ const composerDraftTarget: ScopedThreadRef | DraftId =
655+ routeKind === "server" ? routeThreadRef : props.draftId;
656+ const serverThread = useStore(
657+ useMemo(
658+ () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null),
659+ [routeKind, routeThreadRef],
660+ ),
661+ );
535662 const setStoreThreadError = useStore((store) => store.setError);
536663 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
537− const activeThreadLastVisitedAt = useUiStateStore(
538− (store) => store.threadLastVisitedAtById[threadId],
664+ const activeThreadLastVisitedAt = useUiStateStore((store) =>
665+ routeKind === "server"
666+ ? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))]
667+ : undefined,
539668 );
540669 const settings = useSettings();
541670 const setStickyComposerModelSelection = useComposerDraftStore(
@@ -548,7 +677,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines
548677 select: (params) => parseDiffRouteSearch(params),
549678 });
550679 const { resolvedTheme } = useTheme();
551− const composerDraft = useComposerThreadDraft(threadId);
680+ const composerDraft = useComposerThreadDraft(composerDraftTarget);
552681 const prompt = composerDraft.prompt;
553682 const composerImages = composerDraft.images;
554683 const composerTerminalContexts = composerDraft.terminalContexts;
@@ -591,16 +720,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 36 unmodified lines
591720 );
592721 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
593722 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
594− const getDraftThreadByProjectId = useComposerDraftStore(
595− (store) => store.getDraftThreadByProjectId,
723+ const getDraftSessionByLogicalProjectKey = useComposerDraftStore(
724+ (store) => store.getDraftSessionByLogicalProjectKey,
596725 );
597− const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
598− const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
599− const clearProjectDraftThreadId = useComposerDraftStore(
600− (store) => store.clearProjectDraftThreadId,
726+ const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
727+ const getDraftSession = useComposerDraftStore((store) => store.getDraftSession);
728+ const setLogicalProjectDraftThreadId = useComposerDraftStore(
729+ (store) => store.setLogicalProjectDraftThreadId,
601730 );
602− const draftThread = useComposerDraftStore(
603− (store) => store.draftThreadsByThreadId[threadId] ?? null,
731+ const draftThread = useComposerDraftStore((store) =>
732+ routeKind === "server"
733+ ? store.getDraftSessionByRef(routeThreadRef)
734+ : draftId
735+ ? store.getDraftSession(draftId)
736+ : null,
604737 );
605738 const promptRef = useRef(prompt);
606739 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
@@ -610,8 +743,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 3 unmodified lines
610743 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
611744 optimisticUserMessagesRef.current = optimisticUserMessages;
612745 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts);
613− const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
614− Record<ThreadId, string | null>
746+ const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<
747+ Record<string, string | null>
615748 >({});
616749 const [isConnecting, _setIsConnecting] = useState(false);
617750 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
@@ -689,66 +822,73 @@ export default function ChatView({ threadId }: ChatViewProps) { · 71 unmodified lines
689822 setMessagesScrollElement(element);
690823 }, []);
691824
692− const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
693− const terminalState = useMemo(
694− () => selectThreadTerminalState(terminalStateByThreadId, threadId),
695− [terminalStateByThreadId, threadId],
825+ const terminalState = useTerminalStateStore((state) =>
826+ selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef),
696827 );
697− const openTerminalThreadIds = useMemo(
698− () =>
699− Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) =>
700− nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [],
828+ const openTerminalThreadKeys = useTerminalStateStore(
829+ useShallow((state) =>
830+ Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) =>
831+ nextTerminalState.terminalOpen ? [nextThreadKey] : [],
701832 ),
702− [terminalStateByThreadId],
833+ ),
703834 );
704835 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
705836 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
706837 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);
707838 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);
708839 const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);
709− const serverThreadIds = useStore((state) => state.threadIds);
840+ const serverThreadKeys = useStore(
841+ useShallow((state) =>
842+ selectThreadsAcrossEnvironments(state).map((thread) =>
843+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
844+ ),
845+ ),
846+ );
710847 const storeServerTerminalLaunchContext = useTerminalStateStore(
711− (s) => s.terminalLaunchContextByThreadId[threadId] ?? null,
848+ (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null,
712849 );
713850 const storeClearTerminalLaunchContext = useTerminalStateStore(
714851 (s) => s.clearTerminalLaunchContext,
715852 );
716− const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId);
717− const draftThreadIds = useMemo(
718− () => Object.keys(draftThreadsByThreadId) as ThreadId[],
719− [draftThreadsByThreadId],
853+ const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
854+ const draftThreadKeys = useMemo(
855+ () =>
856+ Object.values(draftThreadsByThreadKey).map((draftThread) =>
857+ scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)),
858+ ),
859+ [draftThreadsByThreadKey],
720860 );
721− const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState<ThreadId[]>([]);
861+ const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState<string[]>([]);
722862
723863 const setPrompt = useCallback(
724864 (nextPrompt: string) => {
725− setComposerDraftPrompt(threadId, nextPrompt);
865+ setComposerDraftPrompt(composerDraftTarget, nextPrompt);
726866 },
727− [setComposerDraftPrompt, threadId],
867+ [composerDraftTarget, setComposerDraftPrompt],
728868 );
729869 const addComposerImage = useCallback(
730870 (image: ComposerImageAttachment) => {
731− addComposerDraftImage(threadId, image);
871+ addComposerDraftImage(composerDraftTarget, image);
732872 },
733− [addComposerDraftImage, threadId],
873+ [addComposerDraftImage, composerDraftTarget],
734874 );
735875 const addComposerImagesToDraft = useCallback(
736876 (images: ComposerImageAttachment[]) => {
737− addComposerDraftImages(threadId, images);
877+ addComposerDraftImages(composerDraftTarget, images);
738878 },
739− [addComposerDraftImages, threadId],
879+ [addComposerDraftImages, composerDraftTarget],
740880 );
741881 const addComposerTerminalContextsToDraft = useCallback(
742882 (contexts: TerminalContextDraft[]) => {
743− addComposerDraftTerminalContexts(threadId, contexts);
883+ addComposerDraftTerminalContexts(composerDraftTarget, contexts);
744884 },
745− [addComposerDraftTerminalContexts, threadId],
885+ [addComposerDraftTerminalContexts, composerDraftTarget],
746886 );
747887 const removeComposerImageFromDraft = useCallback(
748888 (imageId: string) => {
749− removeComposerDraftImage(threadId, imageId);
889+ removeComposerDraftImage(composerDraftTarget, imageId);
750890 },
751− [removeComposerDraftImage, threadId],
891+ [composerDraftTarget, removeComposerDraftImage],
752892 );
753893 const removeComposerTerminalContextFromDraft = useCallback(
754894 (contextId: string) => {
@@ -761,7 +901,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines
761901 const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex);
762902 promptRef.current = nextPrompt.prompt;
763903 setPrompt(nextPrompt.prompt);
764− removeComposerDraftTerminalContext(threadId, contextId);
904+ removeComposerDraftTerminalContext(composerDraftTarget, contextId);
765905 setComposerCursor(nextPrompt.cursor);
766906 setComposerTrigger(
767907 detectComposerTrigger(
@@ -770,13 +910,19 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines
770910 ),
771911 );
772912 },
773− [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId],
913+ [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt],
774914 );
775915
776− const fallbackDraftProject = useStore((state) =>
777− draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined,
916+ const fallbackDraftProjectRef = draftThread
917+ ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
918+ : null;
919+ const fallbackDraftProject = useStore(
920+ useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]),
778921 );
779− const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
922+ const localDraftError =
923+ routeKind === "server" && serverThread
924+ ? null
925+ : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null);
780926 const localDraftThread = useMemo(
781927 () =>
782928 draftThread
@@ -792,20 +938,25 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines
792938 : undefined,
793939 [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId],
794940 );
795− const activeThread = serverThread ?? localDraftThread;
941+ const isServerThread = routeKind === "server" && serverThread !== undefined;
942+ const activeThread = isServerThread ? serverThread : localDraftThread;
796943 const runtimeMode =
797944 composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
798945 const interactionMode =
799946 composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
800− const isServerThread = serverThread !== undefined;
801947 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
802948 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
803949 const diffOpen = rawSearch.diff === "1";
804950 const activeThreadId = activeThread?.id ?? null;
805− const existingOpenTerminalThreadIds = useMemo(() => {
806− const existingThreadIds = new Set<ThreadId>([...serverThreadIds, ...draftThreadIds]);
807− return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId));
808− }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]);
951+ const activeThreadRef = useMemo(
952+ () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
953+ [activeThread],
954+ );
955+ const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
956+ const existingOpenTerminalThreadKeys = useMemo(() => {
957+ const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
958+ return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
959+ }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
809960 const activeLatestTurn = activeThread?.latestTurn ?? null;
810961 const threadPlanCatalog = useThreadPlanCatalog(
811962 useMemo(() => {
@@ -825,12 +976,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 13 unmodified lines
825976 [activeThread?.activities],
826977 );
827978 useEffect(() => {
828− setMountedTerminalThreadIds((currentThreadIds) => {
979+ setMountedTerminalThreadKeys((currentThreadIds) => {
829980 const nextThreadIds = reconcileMountedTerminalThreadIds({
830981 currentThreadIds,
831− openThreadIds: existingOpenTerminalThreadIds,
832− activeThreadId,
833− activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen),
982+ openThreadIds: existingOpenTerminalThreadKeys,
983+ activeThreadId: activeThreadKey,
984+ activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen),
834985 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
835986 });
836987 return currentThreadIds.length === nextThreadIds.length &&
@@ -838,10 +989,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line
838989 ? currentThreadIds
839990 : nextThreadIds;
840991 });
841− }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]);
992+ }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]);
842993 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
843− const activeProject = useStore((state) =>
844− activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined,
994+ const activeProjectRef = activeThread
995+ ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
996+ : null;
997+ const activeProject = useStore(
998+ useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]),
845999 );
8461000
8471001 const openPullRequestDialog = useCallback(
@@ -867,50 +1021,71 @@ export default function ChatView({ threadId }: ChatViewProps) { · 19 unmodified lines
8671021 if (!activeProject) {
8681022 throw new Error("No active project is available for this pull request.");
8691023 }
870− const storedDraftThread = getDraftThreadByProjectId(activeProject.id);
871− if (storedDraftThread) {
872− setDraftThreadContext(storedDraftThread.threadId, input);
873− setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input);
874− if (storedDraftThread.threadId !== threadId) {
1024+ const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id);
1025+ const logicalProjectKey = deriveLogicalProjectKey(activeProject);
1026+ const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey);
1027+ if (storedDraftSession) {
1028+ setDraftThreadContext(storedDraftSession.draftId, input);
1029+ setLogicalProjectDraftThreadId(
1030+ logicalProjectKey,
1031+ activeProjectRef,
1032+ storedDraftSession.draftId,
1033+ {
1034+ threadId: storedDraftSession.threadId,
1035+ ...input,
1036+ },
1037+ );
1038+ if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) {
8751039 await navigate({
876− to: "/$threadId",
877− params: { threadId: storedDraftThread.threadId },
1040+ to: "/draft/$draftId",
1041+ params: buildDraftThreadRouteParams(storedDraftSession.draftId),
8781042 });
8791043 }
880− return storedDraftThread.threadId;
1044+ return storedDraftSession.threadId;
8811045 }
8821046
883− const activeDraftThread = getDraftThread(threadId);
884− if (!isServerThread && activeDraftThread?.projectId === activeProject.id) {
885− setDraftThreadContext(threadId, input);
886− setProjectDraftThreadId(activeProject.id, threadId, input);
887− return threadId;
1047+ const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null;
1048+ if (
1049+ !isServerThread &&
1050+ activeDraftSession?.logicalProjectKey === logicalProjectKey &&
1051+ draftId
1052+ ) {
1053+ setDraftThreadContext(draftId, input);
1054+ setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, {
1055+ threadId: activeDraftSession.threadId,
1056+ createdAt: activeDraftSession.createdAt,
1057+ runtimeMode: activeDraftSession.runtimeMode,
1058+ interactionMode: activeDraftSession.interactionMode,
1059+ ...input,
1060+ });
1061+ return activeDraftSession.threadId;
8881062 }
8891063
890− clearProjectDraftThreadId(activeProject.id);
1064+ const nextDraftId = newDraftId();
8911065 const nextThreadId = newThreadId();
892− setProjectDraftThreadId(activeProject.id, nextThreadId, {
1066+ setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {
1067+ threadId: nextThreadId,
8931068 createdAt: new Date().toISOString(),
8941069 runtimeMode: DEFAULT_RUNTIME_MODE,
8951070 interactionMode: DEFAULT_INTERACTION_MODE,
8961071 ...input,
8971072 });
8981073 await navigate({
899− to: "/$threadId",
900− params: { threadId: nextThreadId },
1074+ to: "/draft/$draftId",
1075+ params: buildDraftThreadRouteParams(nextDraftId),
9011076 });
9021077 return nextThreadId;
9031078 },
9041079 [
9051080 activeProject,
906− clearProjectDraftThreadId,
907− getDraftThread,
908− getDraftThreadByProjectId,
1081+ draftId,
1082+ getDraftSession,
1083+ getDraftSessionByLogicalProjectKey,
9091084 isServerThread,
9101085 navigate,
1086+ routeKind,
9111087 setDraftThreadContext,
912− setProjectDraftThreadId,
913− threadId,
1088+ setLogicalProjectDraftThreadId,
9141089 ],
9151090 );
9161091
@@ -934,12 +1109,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 17 unmodified lines
9341109 const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
9351110 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
9361111
937− markThreadVisited(serverThread.id);
1112+ markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)));
9381113 }, [
9391114 activeLatestTurn?.completedAt,
9401115 activeThreadLastVisitedAt,
9411116 latestTurnSettled,
9421117 markThreadVisited,
1118+ serverThread?.environmentId,
9431119 serverThread?.id,
9441120 ]);
9451121
@@ -959,7 +1135,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 13 unmodified lines
9591135 );
9601136 const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider;
9611137 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
962− threadId,
1138+ threadRef: composerDraftTarget,
9631139 providers: providerStatuses,
9641140 selectedProvider,
9651141 threadModelSelection: activeThread?.modelSelection,
@@ -1358,7 +1534,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 392 unmodified lines
13581534 (debouncerState) => ({ isPending: debouncerState.isPending }),
13591535 );
13601536 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
1361− const gitStatusQuery = useGitStatus(gitCwd);
1537+ const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd });
13621538 const keybindings = useServerKeybindings();
13631539 const availableEditors = useServerAvailableEditors();
13641540 const modelOptionsByProvider = useMemo(
@@ -1394,6 +1570,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 29 unmodified lines
13941570 );
13951571 const workspaceEntriesQuery = useQuery(
13961572 projectSearchEntriesQueryOptions({
1573+ environmentId,
13971574 cwd: gitCwd,
13981575 query: effectivePathQuery,
13991576 enabled: isPathTrigger,
@@ -1531,16 +1708,22 @@ export default function ChatView({ threadId }: ChatViewProps) { · 131 unmodified lines
15311708 [keybindings, nonTerminalShortcutLabelOptions],
15321709 );
15331710 const onToggleDiff = useCallback(() => {
1711+ if (!isServerThread) {
1712+ return;
1713+ }
15341714 void navigate({
1535− to: "/$threadId",
1536− params: { threadId },
1715+ to: "/$environmentId/$threadId",
1716+ params: {
1717+ environmentId,
1718+ threadId,
1719+ },
15371720 replace: true,
15381721 search: (previous) => {
15391722 const rest = stripDiffSearchParams(previous);
15401723 return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
15411724 },
15421725 });
1543− }, [diffOpen, navigate, threadId]);
1726+ }, [diffOpen, environmentId, isServerThread, navigate, threadId]);
15441727
15451728 const envLocked = Boolean(
15461729 activeThread &&
@@ -1561,21 +1744,27 @@ export default function ChatView({ threadId }: ChatViewProps) { · 14 unmodified lines
15611744 (targetThreadId: ThreadId | null, error: string | null) => {
15621745 if (!targetThreadId) return;
15631746 const nextError = sanitizeThreadErrorMessage(error);
1564− if (useStore.getState().threadShellById[targetThreadId] !== undefined) {
1747+ const isCurrentServerThread = shouldWriteThreadErrorToCurrentServerThread({
1748+ serverThread,
1749+ routeThreadRef,
1750+ targetThreadId,
1751+ });
1752+ if (isCurrentServerThread) {
15651753 setStoreThreadError(targetThreadId, nextError);
15661754 return;
15671755 }
1568− setLocalDraftErrorsByThreadId((existing) => {
1569− if ((existing[targetThreadId] ?? null) === nextError) {
1756+ const localDraftErrorKey = draftId ?? targetThreadId;
1757+ setLocalDraftErrorsByDraftId((existing) => {
1758+ if ((existing[localDraftErrorKey] ?? null) === nextError) {
15701759 return existing;
15711760 }
15721761 return {
15731762 ...existing,
1574− [targetThreadId]: nextError,
1763+ [localDraftErrorKey]: nextError,
15751764 };
15761765 });
15771766 },
1578− [setStoreThreadError],
1767+ [draftId, routeThreadRef, serverThread, setStoreThreadError],
15791768 );
15801769
15811770 const focusComposer = useCallback(() => {
@@ -1606,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 24 unmodified lines
16061795 insertion.cursor,
16071796 );
16081797 const inserted = insertComposerDraftTerminalContext(
1609− activeThread.id,
1798+ scopeThreadRef(activeThread.environmentId, activeThread.id),
16101799 insertion.prompt,
16111800 {
16121801 id: randomUUID(),
@@ -1630,30 +1819,30 @@ export default function ChatView({ threadId }: ChatViewProps) { · 17 unmodified lines
16301819 );
16311820 const setTerminalOpen = useCallback(
16321821 (open: boolean) => {
1633− if (!activeThreadId) return;
1634− storeSetTerminalOpen(activeThreadId, open);
1822+ if (!activeThreadRef) return;
1823+ storeSetTerminalOpen(activeThreadRef, open);
16351824 },
1636− [activeThreadId, storeSetTerminalOpen],
1825+ [activeThreadRef, storeSetTerminalOpen],
16371826 );
16381827 const toggleTerminalVisibility = useCallback(() => {
1639− if (!activeThreadId) return;
1828+ if (!activeThreadRef) return;
16401829 setTerminalOpen(!terminalState.terminalOpen);
1641− }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]);
1830+ }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]);
16421831 const splitTerminal = useCallback(() => {
1643− if (!activeThreadId || hasReachedSplitLimit) return;
1832+ if (!activeThreadRef || hasReachedSplitLimit) return;
16441833 const terminalId = `terminal-${randomUUID()}`;
1645− storeSplitTerminal(activeThreadId, terminalId);
1834+ storeSplitTerminal(activeThreadRef, terminalId);
16461835 setTerminalFocusRequestId((value) => value + 1);
1647− }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]);
1836+ }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]);
16481837 const createNewTerminal = useCallback(() => {
1649− if (!activeThreadId) return;
1838+ if (!activeThreadRef) return;
16501839 const terminalId = `terminal-${randomUUID()}`;
1651− storeNewTerminal(activeThreadId, terminalId);
1840+ storeNewTerminal(activeThreadRef, terminalId);
16521841 setTerminalFocusRequestId((value) => value + 1);
1653− }, [activeThreadId, storeNewTerminal]);
1842+ }, [activeThreadRef, storeNewTerminal]);
16541843 const closeTerminal = useCallback(
16551844 (terminalId: string) => {
1656− const api = readNativeApi();
1845+ const api = readEnvironmentApi(environmentId);
16571846 if (!activeThreadId || !api) return;
16581847 const isFinalTerminal = terminalState.terminalIds.length <= 1;
16591848 const fallbackExitWrite = () =>
@@ -1676,10 +1865,18 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines
16761865 } else {
16771866 void fallbackExitWrite();
16781867 }
1679− storeCloseTerminal(activeThreadId, terminalId);
1868+ if (activeThreadRef) {
1869+ storeCloseTerminal(activeThreadRef, terminalId);
1870+ }
16801871 setTerminalFocusRequestId((value) => value + 1);
16811872 },
1682− [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length],
1873+ [
1874+ activeThreadId,
1875+ activeThreadRef,
1876+ environmentId,
1877+ storeCloseTerminal,
1878+ terminalState.terminalIds.length,
1879+ ],
16831880 );
16841881 const runProjectScript = useCallback(
16851882 async (
@@ -1692,7 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines
16921889 rememberAsLastInvoked?: boolean;
16931890 },
16941891 ) => {
1695− const api = readNativeApi();
1892+ const api = readEnvironmentApi(environmentId);
16961893 if (!api || !activeThreadId || !activeProject || !activeThread) return;
16971894 if (options?.rememberAsLastInvoked !== false) {
16981895 setLastInvokedScriptByProjectId((current) => {
@@ -1719,10 +1916,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 20 unmodified lines
17191916 worktreePath: targetWorktreePath,
17201917 });
17211918 setTerminalOpen(true);
1919+ if (!activeThreadRef) {
1920+ return;
1921+ }
17221922 if (shouldCreateNewTerminal) {
1723− storeNewTerminal(activeThreadId, targetTerminalId);
1923+ storeNewTerminal(activeThreadRef, targetTerminalId);
17241924 } else {
1725− storeSetActiveTerminal(activeThreadId, targetTerminalId);
1925+ storeSetActiveTerminal(activeThreadRef, targetTerminalId);
17261926 }
17271927 setTerminalFocusRequestId((value) => value + 1);
17281928
@@ -1769,12 +1969,14 @@ export default function ChatView({ threadId }: ChatViewProps) { · 40 unmodified lines
17691969 activeProject,
17701970 activeThread,
17711971 activeThreadId,
1972+ activeThreadRef,
17721973 gitCwd,
17731974 setTerminalOpen,
17741975 setThreadError,
17751976 storeNewTerminal,
17761977 storeSetActiveTerminal,
17771978 setLastInvokedScriptByProjectId,
1979+ environmentId,
17781980 terminalState.activeTerminalId,
17791981 terminalState.runningTerminalIds,
17801982 terminalState.terminalIds,
@@ -1790,7 +1992,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines
17901992 keybinding?: string | null;
17911993 keybindingCommand: KeybindingCommand;
17921994 }) => {
1793− const api = readNativeApi();
1995+ const api = readEnvironmentApi(environmentId);
17941996 if (!api) return;
17951997
17961998 await api.orchestration.dispatchCommand({
@@ -1806,10 +2008,14 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines
18062008 });
18072009
18082010 if (isElectron && keybindingRule) {
1809− await api.server.upsertKeybinding(keybindingRule);
2011+ const localApi = readLocalApi();
2012+ if (!localApi) {
2013+ throw new Error("Local API unavailable.");
2014+ }
2015+ await localApi.server.upsertKeybinding(keybindingRule);
18102016 }
18112017 },
1812− [],
2018+ [environmentId],
18132019 );
18142020 const saveProjectScript = useCallback(
18152021 async (input: NewProjectScriptInput) => {
@@ -1913,9 +2119,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 97 unmodified lines
19132119 const handleRuntimeModeChange = useCallback(
19142120 (mode: RuntimeMode) => {
19152121 if (mode === runtimeMode) return;
1916− setComposerDraftRuntimeMode(threadId, mode);
2122+ setComposerDraftRuntimeMode(composerDraftTarget, mode);
19172123 if (isLocalDraftThread) {
1918− setDraftThreadContext(threadId, { runtimeMode: mode });
2124+ setDraftThreadContext(composerDraftTarget, { runtimeMode: mode });
19192125 }
19202126 scheduleComposerFocus();
19212127 },
@@ -1923,18 +2129,18 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line
19232129 isLocalDraftThread,
19242130 runtimeMode,
19252131 scheduleComposerFocus,
2132+ composerDraftTarget,
19262133 setComposerDraftRuntimeMode,
19272134 setDraftThreadContext,
1928− threadId,
19292135 ],
19302136 );
19312137
19322138 const handleInteractionModeChange = useCallback(
19332139 (mode: ProviderInteractionMode) => {
19342140 if (mode === interactionMode) return;
1935− setComposerDraftInteractionMode(threadId, mode);
2141+ setComposerDraftInteractionMode(composerDraftTarget, mode);
19362142 if (isLocalDraftThread) {
1937− setDraftThreadContext(threadId, { interactionMode: mode });
2143+ setDraftThreadContext(composerDraftTarget, { interactionMode: mode });
19382144 }
19392145 scheduleComposerFocus();
19402146 },
@@ -1942,9 +2148,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line
19422148 interactionMode,
19432149 isLocalDraftThread,
19442150 scheduleComposerFocus,
2151+ composerDraftTarget,
19452152 setComposerDraftInteractionMode,
19462153 setDraftThreadContext,
1947− threadId,
19482154 ],
19492155 );
19502156 const toggleInteractionMode = useCallback(() => {
@@ -1980,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 29 unmodified lines
19802186 if (!serverThread) {
19812187 return;
19822188 }
1983− const api = readNativeApi();
2189+ const api = readEnvironmentApi(environmentId);
19842190 if (!api) {
19852191 return;
19862192 }
@@ -2020,7 +2226,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 33 unmodified lines
20202226 });
20212227 }
20222228 },
2023− [serverThread],
2229+ [environmentId, serverThread],
20242230 );
20252231
20262232 // Auto-scroll on new messages
@@ -2350,17 +2556,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 323 unmodified lines
23502556 dragDepthRef.current = 0;
23512557 setIsDragOverComposer(false);
23522558 setExpandedImage(null);
2353− }, [resetLocalDispatch, threadId]);
2559+ }, [draftId, resetLocalDispatch, threadId]);
23542560
23552561 useEffect(() => {
23562562 let cancelled = false;
23572563 void (async () => {
23582564 if (composerImages.length === 0) {
2359− clearComposerDraftPersistedAttachments(threadId);
2565+ clearComposerDraftPersistedAttachments(composerDraftTarget);
23602566 return;
23612567 }
23622568 const getPersistedAttachmentsForThread = () =>
2363− useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
2569+ getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];
23642570 try {
23652571 const currentPersistedAttachments = getPersistedAttachmentsForThread();
23662572 const existingPersistedById = new Map(
@@ -2391,7 +2597,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 24 unmodified lines
23912597 return;
23922598 }
23932599 // Stage attachments in persisted draft state first so persist middleware can write them.
2394− syncComposerDraftPersistedAttachments(threadId, serialized);
2600+ syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);
23952601 } catch {
23962602 const currentImageIds = new Set(composerImages.map((image) => image.id));
23972603 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
@@ -2405,17 +2611,18 @@ export default function ChatView({ threadId }: ChatViewProps) { · 7 unmodified lines
24052611 if (cancelled) {
24062612 return;
24072613 }
2408− syncComposerDraftPersistedAttachments(threadId, fallbackAttachments);
2614+ syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);
24092615 }
24102616 })();
24112617 return () => {
24122618 cancelled = true;
24132619 };
24142620 }, [
2621+ composerDraftTarget,
24152622 clearComposerDraftPersistedAttachments,
24162623 composerImages,
2624+ getComposerDraft,
24172625 syncComposerDraftPersistedAttachments,
2418− threadId,
24192626 ]);
24202627
24212628 const closeExpandedImage = useCallback(() => {
@@ -2476,7 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 54 unmodified lines
24762683 useEffect(() => {
24772684 if (!activeThreadId) {
24782685 setTerminalLaunchContext(null);
2479− storeClearTerminalLaunchContext(threadId);
2686+ storeClearTerminalLaunchContext(routeThreadRef);
24802687 return;
24812688 }
24822689 setTerminalLaunchContext((current) => {
@@ -2484,7 +2691,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line
24842691 if (current.threadId === activeThreadId) return current;
24852692 return null;
24862693 });
2487− }, [activeThreadId, storeClearTerminalLaunchContext, threadId]);
2694+ }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]);
24882695
24892696 useEffect(() => {
24902697 if (!activeThreadId || !activeProjectCwd) {
@@ -2502,12 +2709,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 11 unmodified lines
25022709 settledCwd === current.cwd &&
25032710 (activeThreadWorktreePath ?? null) === current.worktreePath
25042711 ) {
2505− storeClearTerminalLaunchContext(activeThreadId);
2712+ if (activeThreadRef) {
2713+ storeClearTerminalLaunchContext(activeThreadRef);
2714+ }
25062715 return null;
25072716 }
25082717 return current;
25092718 });
2510− }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]);
2719+ }, [
2720+ activeProjectCwd,
2721+ activeThreadId,
2722+ activeThreadRef,
2723+ activeThreadWorktreePath,
2724+ storeClearTerminalLaunchContext,
2725+ ]);
25112726
25122727 useEffect(() => {
25132728 if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) {
@@ -2521,11 +2736,14 @@ export default function ChatView({ threadId }: ChatViewProps) { · 7 unmodified lines
25212736 settledCwd === storeServerTerminalLaunchContext.cwd &&
25222737 (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath
25232738 ) {
2524− storeClearTerminalLaunchContext(activeThreadId);
2739+ if (activeThreadRef) {
2740+ storeClearTerminalLaunchContext(activeThreadRef);
2741+ }
25252742 }
25262743 }, [
25272744 activeProjectCwd,
25282745 activeThreadId,
2746+ activeThreadRef,
25292747 activeThreadWorktreePath,
25302748 storeClearTerminalLaunchContext,
25312749 storeServerTerminalLaunchContext,
@@ -2535,11 +2753,16 @@ export default function ChatView({ threadId }: ChatViewProps) { · 3 unmodified lines
25352753 if (terminalState.terminalOpen) {
25362754 return;
25372755 }
2538− if (activeThreadId) {
2539− storeClearTerminalLaunchContext(activeThreadId);
2756+ if (activeThreadRef) {
2757+ storeClearTerminalLaunchContext(activeThreadRef);
25402758 }
25412759 setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current));
2542− }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]);
2760+ }, [
2761+ activeThreadId,
2762+ activeThreadRef,
2763+ storeClearTerminalLaunchContext,
2764+ terminalState.terminalOpen,
2765+ ]);
25432766
25442767 useEffect(() => {
25452768 if (phase !== "running") return;
@@ -2552,16 +2775,16 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines
25522775 }, [phase]);
25532776
25542777 useEffect(() => {
2555− if (!activeThreadId) return;
2556− const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
2778+ if (!activeThreadKey) return;
2779+ const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false;
25572780 const current = Boolean(terminalState.terminalOpen);
25582781
25592782 if (!previous && current) {
2560− terminalOpenByThreadRef.current[activeThreadId] = current;
2783+ terminalOpenByThreadRef.current[activeThreadKey] = current;
25612784 setTerminalFocusRequestId((value) => value + 1);
25622785 return;
25632786 } else if (previous && !current) {
2564− terminalOpenByThreadRef.current[activeThreadId] = current;
2787+ terminalOpenByThreadRef.current[activeThreadKey] = current;
25652788 const frame = window.requestAnimationFrame(() => {
25662789 focusComposer();
25672790 });
@@ -2570,8 +2793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines
25702793 };
25712794 }
25722795
2573− terminalOpenByThreadRef.current[activeThreadId] = current;
2574− }, [activeThreadId, focusComposer, terminalState.terminalOpen]);
2796+ terminalOpenByThreadRef.current[activeThreadKey] = current;
2797+ }, [activeThreadKey, focusComposer, terminalState.terminalOpen]);
25752798
25762799 useEffect(() => {
25772800 const handler = (event: globalThis.KeyboardEvent) => {
@@ -2766,14 +2989,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 188 unmodified lines
27662989
27672990 const onRevertToTurnCount = useCallback(
27682991 async (turnCount: number) => {
2769− const api = readNativeApi();
2770− if (!api || !activeThread || isRevertingCheckpoint) return;
2992+ const api = readEnvironmentApi(environmentId);
2993+ const localApi = readLocalApi();
2994+ if (!api || !localApi || !activeThread || isRevertingCheckpoint) return;
27712995
27722996 if (phase === "running" || isSendBusy || isConnecting) {
27732997 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
27742998 return;
27752999 }
2776− const confirmed = await api.dialogs.confirm(
3000+ const confirmed = await localApi.dialogs.confirm(
27773001 [
27783002 `Revert this thread to checkpoint ${turnCount}?`,
27793003 "This will discard newer messages and turn diffs in this thread.",
@@ -2802,12 +3026,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 22 unmodified lines
28023026 }
28033027 setIsRevertingCheckpoint(false);
28043028 },
2805− [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
3029+ [
3030+ activeThread,
3031+ environmentId,
3032+ isConnecting,
3033+ isRevertingCheckpoint,
3034+ isSendBusy,
3035+ phase,
3036+ setThreadError,
3037+ ],
28063038 );
28073039
28083040 const onSend = async (e?: { preventDefault: () => void }) => {
28093041 e?.preventDefault();
2810− const api = readNativeApi();
3042+ const api = readEnvironmentApi(environmentId);
28113043 if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return;
28123044 if (activePendingProgress) {
28133045 onAdvanceActivePendingUserInput();
@@ -2830,7 +3062,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines
28303062 planMarkdown: activeProposedPlan.planMarkdown,
28313063 });
28323064 promptRef.current = "";
2833− clearComposerDraftContent(activeThread.id);
3065+ clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id));
28343066 setComposerHighlightedItemId(null);
28353067 setComposerCursor(0);
28363068 setComposerTrigger(null);
@@ -2847,7 +3079,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 10 unmodified lines
28473079 if (standaloneSlashCommand) {
28483080 handleInteractionModeChange(standaloneSlashCommand);
28493081 promptRef.current = "";
2850− clearComposerDraftContent(activeThread.id);
3082+ clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id));
28513083 setComposerHighlightedItemId(null);
28523084 setComposerCursor(0);
28533085 setComposerTrigger(null);
@@ -2880,10 +3112,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 26 unmodified lines
28803112 const shouldCreateWorktree =
28813113 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
28823114 if (shouldCreateWorktree && !activeThread.branch) {
2883− setStoreThreadError(
2884− threadIdForSend,
2885− "Select a base branch before sending in New worktree mode.",
2886− );
3115+ setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
28873116 return;
28883117 }
28893118
@@ -2950,7 +3179,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 60 unmodified lines
29503179 });
29513180 }
29523181 promptRef.current = "";
2953− clearComposerDraftContent(threadIdForSend);
3182+ clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend));
29543183 setComposerHighlightedItemId(null);
29553184 setComposerCursor(0);
29563185 setComposerTrigger(null);
@@ -3087,7 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 130 unmodified lines
30873316 };
30883317
30893318 const onInterrupt = async () => {
3090− const api = readNativeApi();
3319+ const api = readEnvironmentApi(environmentId);
30913320 if (!api || !activeThread) return;
30923321 await api.orchestration.dispatchCommand({
30933322 type: "thread.turn.interrupt",
@@ -3099,7 +3328,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 5 unmodified lines
30993328
31003329 const onRespondToApproval = useCallback(
31013330 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
3102− const api = readNativeApi();
3331+ const api = readEnvironmentApi(environmentId);
31033332 if (!api || !activeThreadId) return;
31043333
31053334 setRespondingRequestIds((existing) =>
@@ -3122,12 +3351,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines
31223351 });
31233352 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
31243353 },
3125− [activeThreadId, setThreadError],
3354+ [activeThreadId, environmentId, setThreadError],
31263355 );
31273356
31283357 const onRespondToUserInput = useCallback(
31293358 async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => {
3130− const api = readNativeApi();
3359+ const api = readEnvironmentApi(environmentId);
31313360 if (!api || !activeThreadId) return;
31323361
31333362 setRespondingUserInputRequestIds((existing) =>
@@ -3150,7 +3379,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines
31503379 });
31513380 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
31523381 },
3153− [activeThreadId, setThreadError],
3382+ [activeThreadId, environmentId, setThreadError],
31543383 );
31553384
31563385 const setActivePendingUserInputQuestionIndex = useCallback(
@@ -3166,31 +3395,38 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines
31663395 [activePendingUserInput],
31673396 );
31683397
3169− const onToggleActivePendingUserInputOption = useCallback(
3398+ const onSelectActivePendingUserInputOption = useCallback(
31703399 (questionId: string, optionLabel: string) => {
31713400 if (!activePendingUserInput) {
31723401 return;
31733402 }
3174− const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
3175− if (!question) {
3176− return;
3177− }
3178− setPendingUserInputAnswersByRequestId((existing) => ({
3179− ...existing,
3180− [activePendingUserInput.requestId]: {
3181− ...existing[activePendingUserInput.requestId],
3182− [questionId]: togglePendingUserInputOptionSelection(
3183− question,
3184− existing[activePendingUserInput.requestId]?.[questionId],
3185− optionLabel,
3186− ),
3187− },
3188− }));
3403+ setPendingUserInputAnswersByRequestId((existing) => {
3404+ const question =
3405+ (activePendingProgress?.activeQuestion?.id === questionId
3406+ ? activePendingProgress.activeQuestion
3407+ : undefined) ??
3408+ activePendingUserInput.questions.find((entry) => entry.id === questionId);
3409+ if (!question) {
3410+ return existing;
3411+ }
3412+
3413+ return {
3414+ ...existing,
3415+ [activePendingUserInput.requestId]: {
3416+ ...existing[activePendingUserInput.requestId],
3417+ [questionId]: togglePendingUserInputOptionSelection(
3418+ question,
3419+ existing[activePendingUserInput.requestId]?.[questionId],
3420+ optionLabel,
3421+ ),
3422+ },
3423+ };
3424+ });
31893425 promptRef.current = "";
31903426 setComposerCursor(0);
31913427 setComposerTrigger(null);
31923428 },
3193− [activePendingUserInput],
3429+ [activePendingProgress?.activeQuestion, activePendingUserInput],
31943430 );
31953431
31963432 const onChangeActivePendingUserInputCustomAnswer = useCallback(
@@ -3257,7 +3493,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 60 unmodified lines
32573493 text: string;
32583494 interactionMode: "default" | "plan";
32593495 }) => {
3260− const api = readNativeApi();
3496+ const api = readEnvironmentApi(environmentId);
32613497 if (
32623498 !api ||
32633499 !activeThread ||
@@ -3312,7 +3548,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 48 unmodified lines
33123548
33133549 // Keep the mode toggle and plan-follow-up banner in sync immediately
33143550 // while the same-thread implementation turn is starting.
3315− setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode);
3551+ setComposerDraftInteractionMode(
3552+ scopeThreadRef(activeThread.environmentId, threadIdForSend),
3553+ nextInteractionMode,
3554+ );
33163555
33173556 await api.orchestration.dispatchCommand({
33183557 type: "thread.turn.start",
@@ -3376,11 +3615,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 57 unmodified lines
33763615 setComposerDraftInteractionMode,
33773616 setThreadError,
33783617 selectedModel,
3618+ environmentId,
33793619 ],
33803620 );
33813621
33823622 const onImplementPlanInNewThread = useCallback(async () => {
3383− const api = readNativeApi();
3623+ const api = readEnvironmentApi(environmentId);
33843624 if (
33853625 !api ||
33863626 !activeThread ||
@@ -3452,17 +3692,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 65 unmodified lines
34523692 });
34533693 })
34543694 .then(() => {
3455− return waitForStartedServerThread(nextThreadId);
3695+ return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId));
34563696 })
34573697 .then(() => {
34583698 // Signal that the plan sidebar should open on the new thread.
34593699 planSidebarOpenOnNextThreadRef.current = true;
34603700 return navigate({
3461− to: "/$threadId",
3462− params: { threadId: nextThreadId },
3701+ to: "/$environmentId/$threadId",
3702+ params: {
3703+ environmentId: activeThread.environmentId,
3704+ threadId: nextThreadId,
3705+ },
34633706 });
34643707 })
3465− .catch(async (err) => {
3708+ .catch(async (err: unknown) => {
34663709 await api.orchestration
34673710 .dispatchCommand({
34683711 type: "thread.delete",
@@ -3494,6 +3737,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 25 unmodified lines
34943737 selectedProvider,
34953738 selectedProviderModels,
34963739 selectedModel,
3740+ environmentId,
34973741 ]);
34983742
34993743 const onProviderModelSelect = useCallback(
@@ -3514,7 +3758,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 14 unmodified lines
35143758 provider: resolvedProvider,
35153759 model: resolvedModel,
35163760 };
3517− setComposerDraftModelSelection(activeThread.id, nextModelSelection);
3761+ setComposerDraftModelSelection(
3762+ scopeThreadRef(activeThread.environmentId, activeThread.id),
3763+ nextModelSelection,
3764+ );
35183765 setStickyComposerModelSelection(nextModelSelection);
35193766 scheduleComposerFocus();
35203767 },
@@ -3546,7 +3793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 25 unmodified lines
35463793 );
35473794 const providerTraitsMenuContent = renderProviderTraitsMenuContent({
35483795 provider: selectedProvider,
3549− threadId,
3796+ ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
3797+ ...(routeKind === "draft" && draftId ? { draftId } : {}),
35503798 model: selectedModel,
35513799 models: selectedProviderModels,
35523800 modelOptions: composerModelOptions?.[selectedProvider],
@@ -3555,7 +3803,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines
35553803 });
35563804 const providerTraitsPicker = renderProviderTraitsPicker({
35573805 provider: selectedProvider,
3558− threadId,
3806+ ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
3807+ ...(routeKind === "draft" && draftId ? { draftId } : {}),
35593808 model: selectedModel,
35603809 models: selectedProviderModels,
35613810 modelOptions: composerModelOptions?.[selectedProvider],
@@ -3565,11 +3814,11 @@ export default function ChatView({ threadId }: ChatViewProps) { · 3 unmodified lines
35653814 const onEnvModeChange = useCallback(
35663815 (mode: DraftThreadEnvMode) => {
35673816 if (isLocalDraftThread) {
3568− setDraftThreadContext(threadId, { envMode: mode });
3817+ setDraftThreadContext(composerDraftTarget, { envMode: mode });
35693818 }
35703819 scheduleComposerFocus();
35713820 },
3572− [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId],
3821+ [composerDraftTarget, isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext],
35733822 );
35743823
35753824 const applyPromptReplacement = useCallback(
@@ -3766,7 +4015,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 190 unmodified lines
37664015 setPrompt(nextPrompt);
37674016 if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) {
37684017 setComposerDraftTerminalContexts(
3769− threadId,
4018+ composerDraftTarget,
37704019 syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds),
37714020 );
37724021 }
@@ -3781,8 +4030,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 8 unmodified lines
37814030 composerTerminalContexts,
37824031 onChangeActivePendingUserInputCustomAnswer,
37834032 setPrompt,
4033+ composerDraftTarget,
37844034 setComposerDraftTerminalContexts,
3785− threadId,
37864035 ],
37874036 );
37884037
@@ -3835,9 +4084,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 46 unmodified lines
38354084 const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null;
38364085 const onOpenTurnDiff = useCallback(
38374086 (turnId: TurnId, filePath?: string) => {
4087+ if (!isServerThread) {
4088+ return;
4089+ }
38384090 void navigate({
3839− to: "/$threadId",
3840− params: { threadId },
4091+ to: "/$environmentId/$threadId",
4092+ params: {
4093+ environmentId,
4094+ threadId,
4095+ },
38414096 search: (previous) => {
38424097 const rest = stripDiffSearchParams(previous);
38434098 return filePath
@@ -3846,7 +4101,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines
38464101 },
38474102 });
38484103 },
3849− [navigate, threadId],
4104+ [environmentId, isServerThread, navigate, threadId],
38504105 );
38514106 const onRevertUserMessage = (messageId: MessageId) => {
38524107 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
@@ -3892,6 +4147,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 39 unmodified lines
38924147 )}
38934148 >
38944149 <ChatHeader
4150+ activeThreadEnvironmentId={activeThread.environmentId}
38954151 activeThreadId={activeThread.id}
38964152 activeThreadTitle={activeThread.title}
38974153 activeProjectName={activeProject?.name}
@@ -3959,6 +4215,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 61 unmodified lines
39594215 completionSummary={completionSummary}
39604216 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
39614217 nowIso={nowIso}
4218+ activeThreadEnvironmentId={activeThread.environmentId}
39624219 expandedWorkGroups={expandedWorkGroups}
39634220 onToggleWorkGroup={onToggleWorkGroup}
39644221 onOpenTurnDiff={onOpenTurnDiff}
@@ -4027,7 +4284,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 62 unmodified lines
40274284 respondingRequestIds={respondingRequestIds}
40284285 answers={activePendingDraftAnswers}
40294286 questionIndex={activePendingQuestionIndex}
4030− onToggleOption={onToggleActivePendingUserInputOption}
4287+ onToggleOption={onSelectActivePendingUserInputOption}
40314288 onAdvance={onAdvanceActivePendingUserInput}
40324289 />
40334290 </div>
@@ -4370,7 +4627,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 336 unmodified lines
43704627
43714628 {isGitRepo && (
43724629 <BranchToolbar
4630+ environmentId={activeThread.environmentId}
43734631 threadId={activeThread.id}
4632+ {...(routeKind === "draft" && draftId ? { draftId } : {})}
43744633 onEnvModeChange={onEnvModeChange}
43754634 envLocked={envLocked}
43764635 onComposerFocusRequest={scheduleComposerFocus}
@@ -4383,6 +4642,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines
43834642 <PullRequestThreadDialog
43844643 key={pullRequestDialogState.key}
43854644 open
4645+ environmentId={activeThread.environmentId}
43864646 threadId={activeThread.id}
43874647 cwd={activeProject?.cwd ?? null}
43884648 initialReference={pullRequestDialogState.initialReference}
@@ -4402,6 +4662,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 13 unmodified lines
44024662 <PlanSidebar
44034663 activePlan={activePlan}
44044664 activeProposedPlan={sidebarProposedPlan}
4665+ environmentId={environmentId}
44054666 markdownCwd={gitCwd ?? undefined}
44064667 workspaceRoot={activeWorkspaceRoot}
44074668 timestampFormat={timestampFormat}
@@ -4418,21 +4679,28 @@ export default function ChatView({ threadId }: ChatViewProps) { · 10 unmodified lines
44184679 </div>
44194680 {/* end horizontal flex container */}
44204681
4421− {mountedTerminalThreadIds.map((mountedThreadId) => (
4422− <PersistentThreadTerminalDrawer
4423− key={mountedThreadId}
4424− threadId={mountedThreadId}
4425− visible={mountedThreadId === activeThreadId && terminalState.terminalOpen}
4426− launchContext={
4427− mountedThreadId === activeThreadId ? (activeTerminalLaunchContext ?? null) : null
4428− }
4429− focusRequestId={mountedThreadId === activeThreadId ? terminalFocusRequestId : 0}
4430− splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4431− newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4432− closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4433− onAddTerminalContext={addTerminalContextToDraft}
4434− />
4435− ))}
4682+ {mountedTerminalThreadKeys.flatMap((mountedThreadKey) => {
4683+ const mountedThreadRef = parseScopedThreadKey(mountedThreadKey);
4684+ if (!mountedThreadRef) {
4685+ return [];
4686+ }
4687+ return [
4688+ <PersistentThreadTerminalDrawer
4689+ key={mountedThreadKey}
4690+ threadRef={mountedThreadRef}
4691+ threadId={mountedThreadRef.threadId}
4692+ visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen}
4693+ launchContext={
4694+ mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
4695+ }
4696+ focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0}
4697+ splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4698+ newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4699+ closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4700+ onAddTerminalContext={addTerminalContextToDraft}
4701+ />,
4702+ ];
4703+ })}
44364704
44374705 {expandedImage && expandedImageItem && (
44384706 <div
apps/web/src/components/DiffPanel.tsx+26−15
@@ -2,7 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs"; · 1 unmodified line
22 import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
33 import { useQuery } from "@tanstack/react-query";
44 import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5−import { ThreadId, type TurnId } from "@t3tools/contracts";
5+import { scopeThreadRef } from "@t3tools/client-runtime";
6+import type { TurnId } from "@t3tools/contracts";
67 import {
78 ChevronLeftIcon,
89 ChevronRightIcon,
@@ -22,15 +23,16 @@ import { openInPreferredEditor } from "../editorPreferences"; · 13 unmodified lines
2223 import { useGitStatus } from "~/lib/gitStatusState";
2324 import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
2425 import { cn } from "~/lib/utils";
25−import { readNativeApi } from "../nativeApi";
26+import { readLocalApi } from "../localApi";
2627 import { resolvePathLinkTarget } from "../terminal-links";
2728 import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
2829 import { useTheme } from "../hooks/useTheme";
2930 import { buildPatchCacheKey } from "../lib/diffRendering";
3031 import { resolveDiffThemeName } from "../lib/diffRendering";
3132 import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
32−import { useStore } from "../store";
33−import { createThreadSelector } from "../storeSelectors";
33+import { selectProjectByRef, useStore } from "../store";
34+import { createThreadSelectorByRef } from "../storeSelectors";
35+import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
3436 import { useSettings } from "../hooks/useSettings";
3537 import { formatShortTimestamp } from "../timestampFormat";
3638 import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
@@ -175,22 +177,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 138 unmodified lines
175177 const previousDiffOpenRef = useRef(false);
176178 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
177179 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
178− const routeThreadId = useParams({
180+ const routeThreadRef = useParams({
179181 strict: false,
180− select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
182+ select: (params) => resolveThreadRouteRef(params),
181183 });
182184 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
183185 const diffOpen = diffSearch.diff === "1";
184− const activeThreadId = routeThreadId;
186+ const activeThreadId = routeThreadRef?.threadId ?? null;
185187 const activeThread = useStore(
186− useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]),
188+ useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]),
187189 );
188190 const activeProjectId = activeThread?.projectId ?? null;
189191 const activeProject = useStore((store) =>
190− activeProjectId ? store.projectById[activeProjectId] : undefined,
192+ activeThread && activeProjectId
193+ ? selectProjectByRef(store, {
194+ environmentId: activeThread.environmentId,
195+ projectId: activeProjectId,
196+ })
197+ : undefined,
191198 );
192199 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
193− const gitStatusQuery = useGitStatus(activeCwd ?? null);
200+ const gitStatusQuery = useGitStatus({
201+ environmentId: activeThread?.environmentId ?? null,
202+ cwd: activeCwd ?? null,
203+ });
194204 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
195205 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
196206 useTurnDiffSummaries(activeThread);
@@ -263,6 +273,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 66 unmodified lines
263273 }, [orderedTurnDiffSummaries, selectedTurn]);
264274 const activeCheckpointDiffQuery = useQuery(
265275 checkpointDiffQueryOptions({
276+ environmentId: activeThread?.environmentId ?? null,
266277 threadId: activeThreadId,
267278 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
268279 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
@@ -322,7 +333,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 53 unmodified lines
322333
323334 const openDiffFileInEditor = useCallback(
324335 (filePath: string) => {
325− const api = readNativeApi();
336+ const api = readLocalApi();
326337 if (!api) return;
327338 const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath;
328339 void openInPreferredEditor(api, targetPath).catch((error) => {
@@ -335,8 +346,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 6 unmodified lines
335346 const selectTurn = (turnId: TurnId) => {
336347 if (!activeThread) return;
337348 void navigate({
338− to: "/$threadId",
339− params: { threadId: activeThread.id },
349+ to: "/$environmentId/$threadId",
350+ params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)),
340351 search: (previous) => {
341352 const rest = stripDiffSearchParams(previous);
342353 return { ...rest, diff: "1", diffTurnId: turnId };
@@ -346,8 +357,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 3 unmodified lines
346357 const selectWholeConversation = () => {
347358 if (!activeThread) return;
348359 void navigate({
349− to: "/$threadId",
350− params: { threadId: activeThread.id },
360+ to: "/$environmentId/$threadId",
361+ params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)),
351362 search: (previous) => {
352363 const rest = stripDiffSearchParams(previous);
353364 return { ...rest, diff: "1" };
apps/web/src/components/GitActionsControl.browser.tsx+73−47
@@ -1,3 +1,4 @@
1+import { scopeThreadRef } from "@t3tools/client-runtime";
12 import { ThreadId } from "@t3tools/contracts";
23 import { useState } from "react";
34 import { afterEach, describe, expect, it, vi } from "vitest";
@@ -5,10 +6,24 @@ import { render } from "vitest-browser-react"; · 1 unmodified line
56
67 const THREAD_A = ThreadId.makeUnsafe("thread-a");
78 const THREAD_B = ThreadId.makeUnsafe("thread-b");
9+const ENVIRONMENT_ID = "environment-local" as never;
810 const GIT_CWD = "/repo/project";
911 const BRANCH_NAME = "feature/toast-scope";
1012
13+function createDeferredPromise<T>() {
14+ let resolve!: (value: T) => void;
15+ let reject!: (reason?: unknown) => void;
16+
17+ const promise = new Promise<T>((nextResolve, nextReject) => {
18+ resolve = nextResolve;
19+ reject = nextReject;
20+ });
21+
22+ return { promise, resolve, reject };
23+}
24+
1125 const {
26+ activeRunStackedActionDeferredRef,
1227 invalidateGitQueriesSpy,
1328 refreshGitStatusSpy,
1429 runStackedActionMutateAsyncSpy,
@@ -18,9 +33,10 @@ const { · 3 unmodified lines
1833 toastPromiseSpy,
1934 toastUpdateSpy,
2035 } = vi.hoisted(() => ({
36+ activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },
2137 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),
2238 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),
23− runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)),
39+ runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),
2440 setThreadBranchSpy: vi.fn(),
2541 toastAddSpy: vi.fn(() => "toast-1"),
2642 toastCloseSpy: vi.fn(),
@@ -28,12 +44,8 @@ const { · 1 unmodified line
2844 toastUpdateSpy: vi.fn(),
2945 }));
3046
31−vi.mock("@tanstack/react-query", async () => {
32− const actual =
33− await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
34−
47+vi.mock("@tanstack/react-query", () => {
3548 return {
36− ...actual,
3749 useIsMutating: vi.fn(() => 0),
3850 useMutation: vi.fn((options: { __kind?: string }) => {
3951 if (options.__kind === "run-stacked-action") {
@@ -56,27 +68,7 @@ vi.mock("@tanstack/react-query", async () => { · 16 unmodified lines
5668 isPending: false,
5769 };
5870 }),
59− useQuery: vi.fn((options: { queryKey?: string[] }) => {
60− if (options.queryKey?.[0] === "git-branches") {
61− return {
62− data: {
63− isRepo: true,
64− hasOriginRemote: true,
65− branches: [
66− {
67− name: BRANCH_NAME,
68− current: true,
69− isDefault: false,
70− worktreePath: null,
71− },
72− ],
73− },
74− error: null,
75− };
76− }
77−
78− return { data: null, error: null };
79− }),
71+ useQuery: vi.fn(() => ({ data: null, error: null })),
8072 useQueryClient: vi.fn(() => ({})),
8173 };
8274 });
@@ -123,27 +115,44 @@ vi.mock("~/lib/gitStatusState", () => ({ · 40 unmodified lines
123115 })),
124116 }));
125117
126−vi.mock("~/lib/utils", async () => {
127− const actual = await vi.importActual<typeof import("~/lib/utils")>("~/lib/utils");
128−
129− return {
130− ...actual,
131− newCommandId: vi.fn(() => "command-1"),
132− randomUUID: vi.fn(() => "action-1"),
133− };
134−});
135−
136−vi.mock("~/nativeApi", () => ({
137− readNativeApi: vi.fn(() => null),
118+vi.mock("~/localApi", () => ({
119+ readLocalApi: vi.fn(() => null),
138120 }));
139121
140122 vi.mock("~/store", () => ({
123+ selectEnvironmentState: (
124+ state: { environmentStateById: Record<string, unknown> },
125+ environmentId: string | null,
126+ ) => {
127+ if (!environmentId) {
128+ throw new Error("Missing environment id");
129+ }
130+ const environmentState = state.environmentStateById[environmentId];
131+ if (!environmentState) {
132+ throw new Error(`Unknown environment: ${environmentId}`);
133+ }
134+ return environmentState;
135+ },
141136 useStore: (selector: (state: unknown) => unknown) =>
142137 selector({
143138 setThreadBranch: setThreadBranchSpy,
144− threadShellById: {
145− [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null },
146− [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null },
139+ environmentStateById: {
140+ [ENVIRONMENT_ID]: {
141+ threadShellById: {
142+ [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null },
143+ [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null },
144+ },
145+ threadSessionById: {},
146+ threadTurnStateById: {},
147+ messageIdsByThreadId: {},
148+ messageByThreadId: {},
149+ activityIdsByThreadId: {},
150+ activityByThreadId: {},
151+ proposedPlanIdsByThreadId: {},
152+ proposedPlanByThreadId: {},
153+ turnDiffIdsByThreadId: {},
154+ turnDiffSummaryByThreadId: {},
155+ },
147156 },
148157 }),
149158 }));
@@ -168,7 +177,10 @@ function Harness() { · 18 unmodified lines
168177 <button type="button" onClick={() => setActiveThreadId(THREAD_B)}>
169178 Switch thread
170179 </button>
171− <GitActionsControl gitCwd={GIT_CWD} activeThreadId={activeThreadId} />
180+ <GitActionsControl
181+ gitCwd={GIT_CWD}
182+ activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, activeThreadId)}
183+ />
172184 </>
173185 );
174186 }
@@ -177,6 +189,7 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 2 unmodified lines
177189 afterEach(() => {
178190 vi.useRealTimers();
179191 vi.clearAllMocks();
192+ activeRunStackedActionDeferredRef.current = createDeferredPromise<never>();
180193 document.body.innerHTML = "";
181194 });
182195
@@ -231,6 +244,9 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 48 unmodified lines
231244 }),
232245 );
233246 } finally {
247+ activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup"));
248+ await Promise.resolve();
249+ vi.useRealTimers();
234250 await screen.unmount();
235251 host.remove();
236252 }
@@ -248,9 +264,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 11 unmodified lines
248264
249265 const host = document.createElement("div");
250266 document.body.append(host);
251− const screen = await render(<GitActionsControl gitCwd={GIT_CWD} activeThreadId={THREAD_A} />, {
252− container: host,
253− });
267+ const screen = await render(
268+ <GitActionsControl
269+ gitCwd={GIT_CWD}
270+ activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, THREAD_A)}
271+ />,
272+ {
273+ container: host,
274+ },
275+ );
254276
255277 try {
256278 window.dispatchEvent(new Event("focus"));
@@ -264,11 +286,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 7 unmodified lines
264286
265287 await vi.advanceTimersByTimeAsync(1);
266288 expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1);
267− expect(refreshGitStatusSpy).toHaveBeenCalledWith(GIT_CWD);
289+ expect(refreshGitStatusSpy).toHaveBeenCalledWith({
290+ environmentId: ENVIRONMENT_ID,
291+ cwd: GIT_CWD,
292+ });
268293 } finally {
269294 if (originalVisibilityState) {
270295 Object.defineProperty(document, "visibilityState", originalVisibilityState);
271296 }
297+ vi.useRealTimers();
272298 await screen.unmount();
273299 host.remove();
274300 }
apps/web/src/components/GitActionsControl.tsx+42−20
@@ -1,9 +1,9 @@
1+import { type ScopedThreadRef } from "@t3tools/contracts";
12 import type {
23 GitActionProgressEvent,
34 GitRunStackedActionResult,
45 GitStackedAction,
56 GitStatusResult,
6− ThreadId,
77 } from "@t3tools/contracts";
88 import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
99 import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
@@ -49,12 +49,14 @@ import { · 39 unmodified lines
4949 import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
5050 import { newCommandId, randomUUID } from "~/lib/utils";
5151 import { resolvePathLinkTarget } from "~/terminal-links";
52−import { readNativeApi } from "~/nativeApi";
52+import { readEnvironmentApi } from "~/environmentApi";
53+import { readLocalApi } from "~/localApi";
5354 import { useStore } from "~/store";
55+import { createThreadSelectorByRef } from "~/storeSelectors";
5456
5557 interface GitActionsControlProps {
5658 gitCwd: string | null;
57− activeThreadId: ThreadId | null;
59+ activeThreadRef: ScopedThreadRef | null;
5860 }
5961
6062 interface PendingDefaultBranchAction {
@@ -206,14 +208,18 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { · 145 unmodified lines
206208 return <InfoIcon className={iconClassName} />;
207209 }
208210
209−export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
211+export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) {
212+ const activeThreadId = activeThreadRef?.threadId ?? null;
213+ const activeEnvironmentId = activeThreadRef?.environmentId ?? null;
210214 const threadToastData = useMemo(
211215 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
212216 [activeThreadId],
213217 );
214− const activeServerThread = useStore((store) =>
215− activeThreadId ? store.threadShellById[activeThreadId] : undefined,
218+ const activeServerThreadSelector = useMemo(
219+ () => createThreadSelectorByRef(activeThreadRef),
220+ [activeThreadRef],
216221 );
222+ const activeServerThread = useStore(activeServerThreadSelector);
217223 const setThreadBranch = useStore((store) => store.setThreadBranch);
218224 const queryClient = useQueryClient();
219225 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
@@ -246,7 +252,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 26 unmodified lines
246252 }
247253
248254 const worktreePath = activeServerThread.worktreePath;
249− const api = readNativeApi();
255+ const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined;
250256 if (api) {
251257 void api.orchestration
252258 .dispatchCommand({
@@ -261,7 +267,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 8 unmodified lines
261267
262268 setThreadBranch(activeThreadId, branch, worktreePath);
263269 },
264− [activeServerThread, activeThreadId, setThreadBranch],
270+ [activeEnvironmentId, activeServerThread, activeThreadId, setThreadBranch],
265271 );
266272
267273 const syncThreadBranchAfterGitAction = useCallback(
@@ -276,7 +282,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 8 unmodified lines
276282 [persistThreadBranchSync],
277283 );
278284
279− const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd);
285+ const { data: gitStatus = null, error: gitStatusError } = useGitStatus({
286+ environmentId: activeEnvironmentId,
287+ cwd: gitCwd,
288+ });
280289 // Default to true while loading so we don't flash init controls.
281290 const isRepo = gitStatus?.isRepo ?? true;
282291 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
@@ -287,19 +296,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 4 unmodified lines
287296 const allSelected = excludedFiles.size === 0;
288297 const noneSelected = selectedFiles.length === 0;
289298
290− const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));
299+ const initMutation = useMutation(
300+ gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),
301+ );
291302
292303 const runImmediateGitActionMutation = useMutation(
293304 gitRunStackedActionMutationOptions({
305+ environmentId: activeEnvironmentId,
294306 cwd: gitCwd,
295307 queryClient,
296308 }),
297309 );
298− const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
310+ const pullMutation = useMutation(
311+ gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),
312+ );
299313
300314 const isRunStackedActionRunning =
301− useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;
302− const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
315+ useIsMutating({
316+ mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd),
317+ }) > 0;
318+ const isPullRunning =
319+ useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0;
303320 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;
304321
305322 useEffect(() => {
@@ -372,7 +389,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 66 unmodified lines
372389 }
373390 refreshTimeout = window.setTimeout(() => {
374391 refreshTimeout = null;
375− void refreshGitStatus(gitCwd).catch(() => undefined);
392+ void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch(
393+ () => undefined,
394+ );
376395 }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS);
377396 };
378397 const handleVisibilityChange = () => {
@@ -391,10 +410,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 12 unmodified lines
391410 window.removeEventListener("focus", scheduleRefreshCurrentGitStatus);
392411 document.removeEventListener("visibilitychange", handleVisibilityChange);
393412 };
394− }, [gitCwd]);
413+ }, [activeEnvironmentId, gitCwd]);
395414
396415 const openExistingPr = useCallback(async () => {
397− const api = readNativeApi();
416+ const api = readLocalApi();
398417 if (!api) {
399418 toastManager.add({
400419 type: "error",
@@ -412,7 +431,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 11 unmodified lines
412431 });
413432 return;
414433 }
415− void api.shell.openExternal(prUrl).catch((err) => {
434+ void api.shell.openExternal(prUrl).catch((err: unknown) => {
416435 toastManager.add({
417436 type: "error",
418437 title: "Unable to open PR link",
@@ -601,7 +620,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 182 unmodified lines
601620 toastActionProps = {
602621 children: toastCta.label,
603622 onClick: () => {
604− const api = readNativeApi();
623+ const api = readLocalApi();
605624 if (!api) return;
606625 closeResultToast();
607626 void api.shell.openExternal(toastCta.url);
@@ -760,7 +779,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 152 unmodified lines
760779
761780 const openChangedFileInEditor = useCallback(
762781 (filePath: string) => {
763− const api = readNativeApi();
782+ const api = readLocalApi();
764783 if (!api || !gitCwd) {
765784 toastManager.add({
766785 type: "error",
@@ -836,7 +855,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 69 unmodified lines
836855 <Menu
837856 onOpenChange={(open) => {
838857 if (open) {
839− void refreshGitStatus(gitCwd).catch(() => undefined);
858+ void refreshGitStatus({
859+ environmentId: activeEnvironmentId,
860+ cwd: gitCwd,
861+ }).catch(() => undefined);
840862 }
841863 }}
842864 >
apps/web/src/components/KeybindingsToast.browser.tsx+26−23
@@ -2,6 +2,7 @@ import "../index.css"; · 1 unmodified line
22
33 import {
44 DEFAULT_SERVER_SETTINGS,
5+ EnvironmentId,
56 ORCHESTRATION_WS_METHODS,
67 type MessageId,
78 type OrchestrationReadModel,
@@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } · 10 unmodified lines
1819 import { render } from "vitest-browser-react";
1920
2021 import { useComposerDraftStore } from "../composerDraftStore";
21−import { __resetNativeApiForTests } from "../nativeApi";
22+import { __resetLocalApiForTests } from "../localApi";
2223 import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
2324 import { getServerConfig } from "../rpc/serverState";
2425 import { getRouter } from "../router";
@@ -34,6 +35,7 @@ vi.mock("../lib/gitStatusState", () => ({ · 9 unmodified lines
3435
3536 const THREAD_ID = "thread-kb-toast-test" as ThreadId;
3637 const PROJECT_ID = "project-1" as ProjectId;
38+const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
3739 const NOW_ISO = "2026-03-04T12:00:00.000Z";
3840
3941 interface TestFixture {
@@ -49,6 +51,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); · 9 unmodified lines
4951
5052 function createBaseServerConfig(): ServerConfig {
5153 return {
54+ environment: {
55+ environmentId: LOCAL_ENVIRONMENT_ID,
56+ label: "Local environment",
57+ platform: { os: "darwin" as const, arch: "arm64" as const },
58+ serverVersion: "0.0.0-test",
59+ capabilities: { repositoryIdentity: true },
60+ },
5261 cwd: "/repo/project",
5362 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
5463 keybindings: [],
@@ -155,6 +164,13 @@ function buildFixture(): TestFixture { · 100 unmodified lines
155164 snapshot: createMinimalSnapshot(),
156165 serverConfig: createBaseServerConfig(),
157166 welcome: {
167+ environment: {
168+ environmentId: LOCAL_ENVIRONMENT_ID,
169+ label: "Local environment",
170+ platform: { os: "darwin" as const, arch: "arm64" as const },
171+ serverVersion: "0.0.0-test",
172+ capabilities: { repositoryIdentity: true },
173+ },
158174 cwd: "/repo/project",
159175 projectName: "Project",
160176 bootstrapProjectId: PROJECT_ID,
@@ -286,7 +302,9 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { · 125 unmodified lines
286302 host.style.overflow = "hidden";
287303 document.body.append(host);
288304
289− const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] }));
305+ const router = getRouter(
306+ createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }),
307+ );
290308
291309 const screen = await render(
292310 <AppAtomRegistryProvider>
@@ -347,32 +365,17 @@ describe("Keybindings update toast", () => { · 54 unmodified lines
347365 return [];
348366 },
349367 });
350− await __resetNativeApiForTests();
368+ await __resetLocalApiForTests();
351369 localStorage.clear();
352370 document.body.innerHTML = "";
353371 useComposerDraftStore.setState({
354− draftsByThreadId: {},
355− draftThreadsByThreadId: {},
356− projectDraftThreadIdByProjectId: {},
372+ draftsByThreadKey: {},
373+ draftThreadsByThreadKey: {},
374+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
357375 });
358376 useStore.setState({
359− projectIds: [],
360− projectById: {},
361− threadIds: [],
362− threadIdsByProjectId: {},
363− threadShellById: {},
364− threadSessionById: {},
365− threadTurnStateById: {},
366− messageIdsByThreadId: {},
367− messageByThreadId: {},
368− activityIdsByThreadId: {},
369− activityByThreadId: {},
370− proposedPlanIdsByThreadId: {},
371− proposedPlanByThreadId: {},
372− turnDiffIdsByThreadId: {},
373− turnDiffSummaryByThreadId: {},
374− sidebarThreadSummaryById: {},
375− bootstrapComplete: false,
377+ activeEnvironmentId: null,
378+ environmentStateById: {},
376379 });
377380 });
378381
apps/web/src/components/PlanSidebar.tsx+6−3
@@ -1,4 +1,5 @@
11 import { memo, useState, useCallback } from "react";
2+import type { EnvironmentId } from "@t3tools/contracts";
23 import { type TimestampFormat } from "@t3tools/contracts/settings";
34 import { Badge } from "./ui/badge";
45 import { Button } from "./ui/button";
@@ -24,7 +25,7 @@ import { · 19 unmodified lines
2425 stripDisplayedPlanMarkdown,
2526 } from "../proposedPlan";
2627 import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
27−import { readNativeApi } from "~/nativeApi";
28+import { readEnvironmentApi } from "~/environmentApi";
2829 import { toastManager } from "./ui/toast";
2930 import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
3031
@@ -53,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode { · 22 unmodified lines
5354 interface PlanSidebarProps {
5455 activePlan: ActivePlanState | null;
5556 activeProposedPlan: LatestProposedPlanState | null;
57+ environmentId: EnvironmentId;
5658 markdownCwd: string | undefined;
5759 workspaceRoot: string | undefined;
5860 timestampFormat: TimestampFormat;
@@ -62,6 +64,7 @@ interface PlanSidebarProps { · 3 unmodified lines
6264 const PlanSidebar = memo(function PlanSidebar({
6365 activePlan,
6466 activeProposedPlan,
67+ environmentId,
6568 markdownCwd,
6669 workspaceRoot,
6770 timestampFormat,
@@ -87,7 +90,7 @@ const PlanSidebar = memo(function PlanSidebar({ · 19 unmodified lines
8790 }, [planMarkdown]);
8891
8992 const handleSaveToWorkspace = useCallback(() => {
90− const api = readNativeApi();
93+ const api = readEnvironmentApi(environmentId);
9194 if (!api || !workspaceRoot || !planMarkdown) return;
9295 const filename = buildProposedPlanMarkdownFilename(planMarkdown);
9396 setIsSavingToWorkspace(true);
@@ -115,7 +118,7 @@ const PlanSidebar = memo(function PlanSidebar({ · 21 unmodified lines
115118 () => setIsSavingToWorkspace(false),
116119 () => setIsSavingToWorkspace(false),
117120 );
118− }, [planMarkdown, workspaceRoot]);
121+ }, [environmentId, planMarkdown, workspaceRoot]);
119122
120123 return (
121124 <div className="flex h-full w-[340px] shrink-0 flex-col border-l border-border/70 bg-card/50">
apps/web/src/components/PullRequestThreadDialog.tsx+7−3
@@ -1,4 +1,4 @@
1−import type { GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts";
1+import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts";
22 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
33 import { useDebouncedValue } from "@tanstack/react-pacer";
44 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner"; · 19 unmodified lines
2424
2525 interface PullRequestThreadDialogProps {
2626 open: boolean;
27+ environmentId: EnvironmentId;
2728 threadId: ThreadId;
2829 cwd: string | null;
2930 initialReference: string | null;
@@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps { · 3 unmodified lines
3334
3435 export function PullRequestThreadDialog({
3536 open,
37+ environmentId,
3638 threadId,
3739 cwd,
3840 initialReference,
@@ -72,6 +74,7 @@ export function PullRequestThreadDialog({ · 33 unmodified lines
7274 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);
7375 const resolvePullRequestQuery = useQuery(
7476 gitResolvePullRequestQueryOptions({
77+ environmentId,
7578 cwd,
7679 reference: open ? parsedDebouncedReference : null,
7780 }),
@@ -83,13 +86,14 @@ export function PullRequestThreadDialog({ · 5 unmodified lines
8386 const cached = queryClient.getQueryData<GitResolvePullRequestResult>([
8487 "git",
8588 "pull-request",
89+ environmentId,
8690 cwd,
8791 parsedReference,
8892 ]);
8993 return cached?.pullRequest ?? null;
90− }, [cwd, parsedReference, queryClient]);
94+ }, [cwd, environmentId, parsedReference, queryClient]);
9195 const preparePullRequestThreadMutation = useMutation(
92− gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }),
96+ gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }),
9397 );
9498
9599 const liveResolvedPullRequest =
apps/web/src/components/Sidebar.logic.test.ts+5−1
@@ -20,7 +20,7 @@ import { · 19 unmodified lines
2020 sortThreadsForSidebar,
2121 THREAD_JUMP_HINT_SHOW_DELAY_MS,
2222 } from "./Sidebar.logic";
23−import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts";
23+import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts";
2424 import {
2525 DEFAULT_INTERACTION_MODE,
2626 DEFAULT_RUNTIME_MODE,
@@ -28,6 +28,8 @@ import { · 1 unmodified line
2828 type Thread,
2929 } from "../types";
3030
31+const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
32+
3133 function makeLatestTurn(overrides?: {
3234 completedAt?: string | null;
3335 startedAt?: string | null;
@@ -625,6 +627,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { · 591 unmodified lines
625627 const { defaultModelSelection, ...rest } = overrides;
626628 return {
627629 id: ProjectId.makeUnsafe("project-1"),
630+ environmentId: localEnvironmentId,
628631 name: "Project",
629632 cwd: "/tmp/project",
630633 defaultModelSelection: {
@@ -642,6 +645,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { · 11 unmodified lines
642645 function makeThread(overrides: Partial<Thread> = {}): Thread {
643646 return {
644647 id: ThreadId.makeUnsafe("thread-1"),
648+ environmentId: localEnvironmentId,
645649 codexThreadId: null,
646650 projectId: ProjectId.makeUnsafe("project-1"),
647651 title: "Thread",
apps/web/src/components/Sidebar.tsx+2083−1402
@@ -12,20 +12,7 @@ import { · 11 unmodified lines
1212 } from "lucide-react";
1313 import { ProjectFavicon } from "./ProjectFavicon";
1414 import { autoAnimate } from "@formkit/auto-animate";
15−import {
16− useCallback,
17− useEffect,
18− useMemo,
19− useRef,
20− useState,
21− type Dispatch,
22− type KeyboardEvent,
23− type MouseEvent,
24− type MutableRefObject,
25− type PointerEvent,
26− type ReactNode,
27− type SetStateAction,
28−} from "react";
15+import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
2916 import { useShallow } from "zustand/react/shallow";
3017 import {
3118 DndContext,
@@ -45,11 +32,20 @@ import { CSS } from "@dnd-kit/utilities"; · 13 unmodified lines
4532 import {
4633 DEFAULT_MODEL_BY_PROVIDER,
4734 type DesktopUpdateState,
35+ type EnvironmentId,
4836 ProjectId,
37+ type ScopedThreadRef,
38+ type ThreadEnvMode,
4939 ThreadId,
5040 type GitStatusResult,
5141 } from "@t3tools/contracts";
52−import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router";
42+import {
43+ scopedProjectKey,
44+ scopedThreadKey,
45+ scopeProjectRef,
46+ scopeThreadRef,
47+} from "@t3tools/client-runtime";
48+import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
5349 import {
5450 type SidebarProjectSortOrder,
5551 type SidebarThreadSortOrder,
@@ -58,7 +54,13 @@ import { isElectron } from "../env"; · 2 unmodified lines
5854 import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
5955 import { isTerminalFocused } from "../lib/terminalFocus";
6056 import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
61−import { useStore } from "../store";
57+import {
58+ selectProjectsAcrossEnvironments,
59+ selectSidebarThreadsForProjectRef,
60+ selectSidebarThreadsAcrossEnvironments,
61+ selectThreadByRef,
62+ useStore,
63+} from "../store";
6264 import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
6365 import { useUiStateStore } from "../uiStateStore";
6466 import {
@@ -70,11 +72,16 @@ import { · 5 unmodified lines
7072 threadTraversalDirectionFromCommand,
7173 } from "../keybindings";
7274 import { useGitStatus } from "../lib/gitStatusState";
73−import { readNativeApi } from "../nativeApi";
75+import { readLocalApi } from "../localApi";
7476 import { useComposerDraftStore } from "../composerDraftStore";
7577 import { useHandleNewThread } from "../hooks/useHandleNewThread";
7678
7779 import { useThreadActions } from "../hooks/useThreadActions";
80+import {
81+ buildThreadRouteParams,
82+ resolveThreadRouteRef,
83+ resolveThreadRouteTarget,
84+} from "../threadRoutes";
7885 import { toastManager } from "./ui/toast";
7986 import { formatRelativeTimeLabel } from "../timestampFormat";
8087 import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
@@ -109,8 +116,6 @@ import { · 28 unmodified lines
109116 import { useThreadSelectionStore } from "../threadSelectionStore";
110117 import { isNonEmpty as isNonEmptyString } from "effect/String";
111118 import {
112− getVisibleSidebarThreadIds,
113− getVisibleThreadsForProject,
114119 resolveAdjacentThreadId,
115120 isContextMenuPointerDown,
116121 resolveProjectStatusIndicator,
@@ -123,12 +128,14 @@ import { · 6 unmodified lines
123128 sortProjectsForSidebar,
124129 sortThreadsForSidebar,
125130 useThreadJumpHintVisibility,
131+ ThreadStatusPill,
126132 } from "./Sidebar.logic";
127133 import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
128134 import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
135+import { readEnvironmentApi } from "../environmentApi";
129136 import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
130137 import { useServerKeybindings } from "../rpc/serverState";
131−import type { Project } from "../types";
138+import type { Project, SidebarThreadSummary } from "../types";
132139 const THREAD_PREVIEW_LIMIT = 6;
133140 const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
134141 updated_at: "Last user message",
@@ -143,9 +150,58 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { · 8 unmodified lines
143150 duration: 180,
144151 easing: "ease-out",
145152 } as const;
153+const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();
154+
155+function threadJumpLabelMapsEqual(
156+ left: ReadonlyMap<string, string>,
157+ right: ReadonlyMap<string, string>,
158+): boolean {
159+ if (left === right) {
160+ return true;
161+ }
162+ if (left.size !== right.size) {
163+ return false;
164+ }
165+ for (const [key, value] of left) {
166+ if (right.get(key) !== value) {
167+ return false;
168+ }
169+ }
170+ return true;
171+}
172+
173+function buildThreadJumpLabelMap(input: {
174+ keybindings: ReturnType<typeof useServerKeybindings>;
175+ platform: string;
176+ terminalOpen: boolean;
177+ threadJumpCommandByKey: ReadonlyMap<
178+ string,
179+ NonNullable<ReturnType<typeof threadJumpCommandForIndex>>
180+ >;
181+}): ReadonlyMap<string, string> {
182+ if (input.threadJumpCommandByKey.size === 0) {
183+ return EMPTY_THREAD_JUMP_LABELS;
184+ }
185+
186+ const shortcutLabelOptions = {
187+ platform: input.platform,
188+ context: {
189+ terminalFocus: false,
190+ terminalOpen: input.terminalOpen,
191+ },
192+ } as const;
193+ const mapping = new Map<string, string>();
194+ for (const [threadKey, command] of input.threadJumpCommandByKey) {
195+ const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions);
196+ if (label) {
197+ mapping.set(threadKey, label);
198+ }
199+ }
200+ return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;
201+}
146202
147203 type SidebarProjectSnapshot = Project & {
148− expanded: boolean;
204+ projectKey: string;
149205 };
150206 interface TerminalStatusIndicator {
151207 label: "Terminal process running";
@@ -166,7 +222,7 @@ function ThreadStatusLabel({ · 14 unmodified lines
166222 status,
167223 compact = false,
168224 }: {
169− status: NonNullable<ReturnType<typeof resolveThreadStatusPill>>;
225+ status: ThreadStatusPill;
170226 compact?: boolean;
171227 }) {
172228 if (compact) {
@@ -255,57 +311,81 @@ function resolveThreadPr( · 82 unmodified lines
255311 }
256312
257313 interface SidebarThreadRowProps {
258− threadId: ThreadId;
314+ thread: SidebarThreadSummary;
259315 projectCwd: string | null;
260− orderedProjectThreadIds: readonly ThreadId[];
261− routeThreadId: ThreadId | null;
262− selectedThreadIds: ReadonlySet<ThreadId>;
263− showThreadJumpHints: boolean;
316+ orderedProjectThreadKeys: readonly string[];
317+ isActive: boolean;
264318 jumpLabel: string | null;
265319 appSettingsConfirmThreadArchive: boolean;
266− renamingThreadId: ThreadId | null;
320+ renamingThreadKey: string | null;
267321 renamingTitle: string;
268322 setRenamingTitle: (title: string) => void;
269− onRenamingInputMount: (element: HTMLInputElement | null) => void;
270− hasRenameCommitted: () => boolean;
271− markRenameCommitted: () => void;
272− confirmingArchiveThreadId: ThreadId | null;
273− setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>;
274− confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>;
323+ renamingInputRef: React.RefObject<HTMLInputElement | null>;
324+ renamingCommittedRef: React.RefObject<boolean>;
325+ confirmingArchiveThreadKey: string | null;
326+ setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>;
327+ confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>;
275328 handleThreadClick: (
276− event: MouseEvent,
277− threadId: ThreadId,
278− orderedProjectThreadIds: readonly ThreadId[],
329+ event: React.MouseEvent,
330+ threadRef: ScopedThreadRef,
331+ orderedProjectThreadKeys: readonly string[],
279332 ) => void;
280− navigateToThread: (threadId: ThreadId) => void;
333+ navigateToThread: (threadRef: ScopedThreadRef) => void;
281334 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
282335 handleThreadContextMenu: (
283− threadId: ThreadId,
336+ threadRef: ScopedThreadRef,
284337 position: { x: number; y: number },
285338 ) => Promise<void>;
286339 clearSelection: () => void;
287− commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise<void>;
340+ commitRename: (
341+ threadRef: ScopedThreadRef,
342+ newTitle: string,
343+ originalTitle: string,
344+ ) => Promise<void>;
288345 cancelRename: () => void;
289− attemptArchiveThread: (threadId: ThreadId) => Promise<void>;
290− openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void;
346+ attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>;
347+ openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;
291348 }
292349
293−function SidebarThreadRow(props: SidebarThreadRowProps) {
294− const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]);
295− const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]);
350+const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
351+ const {
352+ orderedProjectThreadKeys,
353+ isActive,
354+ jumpLabel,
355+ appSettingsConfirmThreadArchive,
356+ renamingThreadKey,
357+ renamingTitle,
358+ setRenamingTitle,
359+ renamingInputRef,
360+ renamingCommittedRef,
361+ confirmingArchiveThreadKey,
362+ setConfirmingArchiveThreadKey,
363+ confirmArchiveButtonRefs,
364+ handleThreadClick,
365+ navigateToThread,
366+ handleMultiSelectContextMenu,
367+ handleThreadContextMenu,
368+ clearSelection,
369+ commitRename,
370+ cancelRename,
371+ attemptArchiveThread,
372+ openPrLink,
373+ thread,
374+ } = props;
375+ const threadRef = scopeThreadRef(thread.environmentId, thread.id);
376+ const threadKey = scopedThreadKey(threadRef);
377+ const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
378+ const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
379+ const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0);
296380 const runningTerminalIds = useTerminalStateStore(
297381 (state) =>
298− selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds,
382+ selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds,
299383 );
300− const gitCwd = thread?.worktreePath ?? props.projectCwd;
301− const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null);
302−
303− if (!thread) {
304− return null;
305− }
306−
307− const isActive = props.routeThreadId === thread.id;
308− const isSelected = props.selectedThreadIds.has(thread.id);
384+ const gitCwd = thread.worktreePath ?? props.projectCwd;
385+ const gitStatus = useGitStatus({
386+ environmentId: thread.environmentId,
387+ cwd: thread.branch != null ? gitCwd : null,
388+ });
309389 const isHighlighted = isActive || isSelected;
310390 const isThreadRunning =
311391 thread.session?.status === "running" && thread.session.activeTurnId != null;
@@ -318,32 +398,173 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 6 unmodified lines
318398 const pr = resolveThreadPr(thread.branch, gitStatus.data);
319399 const prStatus = prStatusIndicator(pr);
320400 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
321− const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning;
401+ const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
322402 const threadMetaClassName = isConfirmingArchive
323403 ? "pointer-events-none opacity-0"
324404 : !isThreadRunning
325405 ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"
326406 : "pointer-events-none";
407+ const clearConfirmingArchive = useCallback(() => {
408+ setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current));
409+ }, [setConfirmingArchiveThreadKey, threadKey]);
410+ const handleMouseLeave = useCallback(() => {
411+ clearConfirmingArchive();
412+ }, [clearConfirmingArchive]);
413+ const handleBlurCapture = useCallback(
414+ (event: React.FocusEvent<HTMLLIElement>) => {
415+ const currentTarget = event.currentTarget;
416+ requestAnimationFrame(() => {
417+ if (currentTarget.contains(document.activeElement)) {
418+ return;
419+ }
420+ clearConfirmingArchive();
421+ });
422+ },
423+ [clearConfirmingArchive],
424+ );
425+ const handleRowClick = useCallback(
426+ (event: React.MouseEvent) => {
427+ handleThreadClick(event, threadRef, orderedProjectThreadKeys);
428+ },
429+ [handleThreadClick, orderedProjectThreadKeys, threadRef],
430+ );
431+ const handleRowKeyDown = useCallback(
432+ (event: React.KeyboardEvent) => {
433+ if (event.key !== "Enter" && event.key !== " ") return;
434+ event.preventDefault();
435+ navigateToThread(threadRef);
436+ },
437+ [navigateToThread, threadRef],
438+ );
439+ const handleRowContextMenu = useCallback(
440+ (event: React.MouseEvent) => {
441+ event.preventDefault();
442+ if (hasSelection && isSelected) {
443+ void handleMultiSelectContextMenu({
444+ x: event.clientX,
445+ y: event.clientY,
446+ });
447+ return;
448+ }
449+
450+ if (hasSelection) {
451+ clearSelection();
452+ }
453+ void handleThreadContextMenu(threadRef, {
454+ x: event.clientX,
455+ y: event.clientY,
456+ });
457+ },
458+ [
459+ clearSelection,
460+ handleMultiSelectContextMenu,
461+ handleThreadContextMenu,
462+ hasSelection,
463+ isSelected,
464+ threadRef,
465+ ],
466+ );
467+ const handlePrClick = useCallback(
468+ (event: React.MouseEvent<HTMLButtonElement>) => {
469+ if (!prStatus) return;
470+ openPrLink(event, prStatus.url);
471+ },
472+ [openPrLink, prStatus],
473+ );
474+ const handleRenameInputRef = useCallback(
475+ (element: HTMLInputElement | null) => {
476+ if (element && renamingInputRef.current !== element) {
477+ renamingInputRef.current = element;
478+ element.focus();
479+ element.select();
480+ }
481+ },
482+ [renamingInputRef],
483+ );
484+ const handleRenameInputChange = useCallback(
485+ (event: React.ChangeEvent<HTMLInputElement>) => {
486+ setRenamingTitle(event.target.value);
487+ },
488+ [setRenamingTitle],
489+ );
490+ const handleRenameInputKeyDown = useCallback(
491+ (event: React.KeyboardEvent<HTMLInputElement>) => {
492+ event.stopPropagation();
493+ if (event.key === "Enter") {
494+ event.preventDefault();
495+ renamingCommittedRef.current = true;
496+ void commitRename(threadRef, renamingTitle, thread.title);
497+ } else if (event.key === "Escape") {
498+ event.preventDefault();
499+ renamingCommittedRef.current = true;
500+ cancelRename();
501+ }
502+ },
503+ [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef],
504+ );
505+ const handleRenameInputBlur = useCallback(() => {
506+ if (!renamingCommittedRef.current) {
507+ void commitRename(threadRef, renamingTitle, thread.title);
508+ }
509+ }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]);
510+ const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => {
511+ event.stopPropagation();
512+ }, []);
513+ const handleConfirmArchiveRef = useCallback(
514+ (element: HTMLButtonElement | null) => {
515+ if (element) {
516+ confirmArchiveButtonRefs.current.set(threadKey, element);
517+ } else {
518+ confirmArchiveButtonRefs.current.delete(threadKey);
519+ }
520+ },
521+ [confirmArchiveButtonRefs, threadKey],
522+ );
523+ const stopPropagationOnPointerDown = useCallback(
524+ (event: React.PointerEvent<HTMLButtonElement>) => {
525+ event.stopPropagation();
526+ },
527+ [],
528+ );
529+ const handleConfirmArchiveClick = useCallback(
530+ (event: React.MouseEvent<HTMLButtonElement>) => {
531+ event.preventDefault();
532+ event.stopPropagation();
533+ clearConfirmingArchive();
534+ void attemptArchiveThread(threadRef);
535+ },
536+ [attemptArchiveThread, clearConfirmingArchive, threadRef],
537+ );
538+ const handleStartArchiveConfirmation = useCallback(
539+ (event: React.MouseEvent<HTMLButtonElement>) => {
540+ event.preventDefault();
541+ event.stopPropagation();
542+ setConfirmingArchiveThreadKey(threadKey);
543+ requestAnimationFrame(() => {
544+ confirmArchiveButtonRefs.current.get(threadKey)?.focus();
545+ });
546+ },
547+ [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey],
548+ );
549+ const handleArchiveImmediateClick = useCallback(
550+ (event: React.MouseEvent<HTMLButtonElement>) => {
551+ event.preventDefault();
552+ event.stopPropagation();
553+ void attemptArchiveThread(threadRef);
554+ },
555+ [attemptArchiveThread, threadRef],
556+ );
557+ const rowButtonRender = useMemo(() => <div role="button" tabIndex={0} />, []);
327558
328559 return (
329560 <SidebarMenuSubItem
330561 className="w-full"
331562 data-thread-item
332− onMouseLeave={() => {
333− props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
334− }}
335− onBlurCapture={(event) => {
336− const currentTarget = event.currentTarget;
337− requestAnimationFrame(() => {
338− if (currentTarget.contains(document.activeElement)) {
339− return;
340− }
341− props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
342− });
343− }}
563+ onMouseLeave={handleMouseLeave}
564+ onBlurCapture={handleBlurCapture}
344565 >
345566 <SidebarMenuSubButton
346− render={<div role="button" tabIndex={0} />}
567+ render={rowButtonRender}
347568 size="sm"
348569 isActive={isActive}
349570 data-testid={`thread-row-${thread.id}`}
@@ -351,31 +572,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 1 unmodified line
351572 isActive,
352573 isSelected,
353574 })} relative isolate`}
354− onClick={(event) => {
355− props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds);
356− }}
357− onKeyDown={(event) => {
358− if (event.key !== "Enter" && event.key !== " ") return;
359− event.preventDefault();
360− props.navigateToThread(thread.id);
361− }}
362− onContextMenu={(event) => {
363− event.preventDefault();
364− if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) {
365− void props.handleMultiSelectContextMenu({
366− x: event.clientX,
367− y: event.clientY,
368− });
369− } else {
370− if (props.selectedThreadIds.size > 0) {
371− props.clearSelection();
372− }
373− void props.handleThreadContextMenu(thread.id, {
374− x: event.clientX,
375− y: event.clientY,
376− });
377− }
378− }}
575+ onClick={handleRowClick}
576+ onKeyDown={handleRowKeyDown}
577+ onContextMenu={handleRowContextMenu}
379578 >
380579 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
381580 {prStatus && (
@@ -386,9 +585,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 4 unmodified lines
386585 type="button"
387586 aria-label={prStatus.tooltip}
388587 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
389− onClick={(event) => {
390− props.openPrLink(event, prStatus.url);
391− }}
588+ onClick={handlePrClick}
392589 >
393590 <GitPullRequestIcon className="size-3" />
394591 </button>
@@ -398,30 +595,15 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 3 unmodified lines
398595 </Tooltip>
399596 )}
400597 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
401− {props.renamingThreadId === thread.id ? (
598+ {renamingThreadKey === threadKey ? (
402599 <input
403− ref={props.onRenamingInputMount}
600+ ref={handleRenameInputRef}
404601 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
405− value={props.renamingTitle}
406− onChange={(event) => props.setRenamingTitle(event.target.value)}
407− onKeyDown={(event) => {
408− event.stopPropagation();
409− if (event.key === "Enter") {
410− event.preventDefault();
411− props.markRenameCommitted();
412− void props.commitRename(thread.id, props.renamingTitle, thread.title);
413− } else if (event.key === "Escape") {
414− event.preventDefault();
415− props.markRenameCommitted();
416− props.cancelRename();
417− }
418− }}
419− onBlur={() => {
420− if (!props.hasRenameCommitted()) {
421− void props.commitRename(thread.id, props.renamingTitle, thread.title);
422− }
423− }}
424− onClick={(event) => event.stopPropagation()}
602+ value={renamingTitle}
603+ onChange={handleRenameInputChange}
604+ onKeyDown={handleRenameInputKeyDown}
605+ onBlur={handleRenameInputBlur}
606+ onClick={handleRenameInputClick}
425607 />
426608 ) : (
427609 <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
@@ -441,34 +623,19 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 13 unmodified lines
441623 <div className="flex min-w-12 justify-end">
442624 {isConfirmingArchive ? (
443625 <button
444− ref={(element) => {
445− if (element) {
446− props.confirmArchiveButtonRefs.current.set(thread.id, element);
447− } else {
448− props.confirmArchiveButtonRefs.current.delete(thread.id);
449− }
450− }}
626+ ref={handleConfirmArchiveRef}
451627 type="button"
452628 data-thread-selection-safe
453629 data-testid={`thread-archive-confirm-${thread.id}`}
454630 aria-label={`Confirm archive ${thread.title}`}
455631 className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40"
456− onPointerDown={(event) => {
457− event.stopPropagation();
458− }}
459− onClick={(event) => {
460− event.preventDefault();
461− event.stopPropagation();
462− props.setConfirmingArchiveThreadId((current) =>
463− current === thread.id ? null : current,
464− );
465− void props.attemptArchiveThread(thread.id);
466− }}
632+ onPointerDown={stopPropagationOnPointerDown}
633+ onClick={handleConfirmArchiveClick}
467634 >
468635 Confirm
469636 </button>
470637 ) : !isThreadRunning ? (
471− props.appSettingsConfirmThreadArchive ? (
638+ appSettingsConfirmThreadArchive ? (
472639 <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
473640 <button
474641 type="button"
@@ -476,17 +643,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 1 unmodified line
476643 data-testid={`thread-archive-${thread.id}`}
477644 aria-label={`Archive ${thread.title}`}
478645 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
479− onPointerDown={(event) => {
480− event.stopPropagation();
481− }}
482− onClick={(event) => {
483− event.preventDefault();
484− event.stopPropagation();
485− props.setConfirmingArchiveThreadId(thread.id);
486− requestAnimationFrame(() => {
487− props.confirmArchiveButtonRefs.current.get(thread.id)?.focus();
488− });
489− }}
646+ onPointerDown={stopPropagationOnPointerDown}
647+ onClick={handleStartArchiveConfirmation}
490648 >
491649 <ArchiveIcon className="size-3.5" />
492650 </button>
@@ -502,14 +660,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 9 unmodified lines
502660 data-testid={`thread-archive-${thread.id}`}
503661 aria-label={`Archive ${thread.title}`}
504662 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
505− onPointerDown={(event) => {
506− event.stopPropagation();
507− }}
508− onClick={(event) => {
509− event.preventDefault();
510− event.stopPropagation();
511− void props.attemptArchiveThread(thread.id);
512− }}
663+ onPointerDown={stopPropagationOnPointerDown}
664+ onClick={handleArchiveImmediateClick}
513665 >
514666 <ArchiveIcon className="size-3.5" />
515667 </button>
@@ -521,12 +673,12 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 5 unmodified lines
521673 )
522674 ) : null}
523675 <span className={threadMetaClassName}>
524− {props.showThreadJumpHints && props.jumpLabel ? (
676+ {jumpLabel ? (
525677 <span
526678 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
527− title={props.jumpLabel}
679+ title={jumpLabel}
528680 >
529− {props.jumpLabel}
681+ {jumpLabel}
530682 </span>
531683 ) : (
532684 <span
@@ -545,248 +697,288 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 12 unmodified lines
545697 </SidebarMenuSubButton>
546698 </SidebarMenuSubItem>
547699 );
700+});
701+
702+interface SidebarProjectThreadListProps {
703+ projectKey: string;
704+ projectExpanded: boolean;
705+ hasOverflowingThreads: boolean;
706+ hiddenThreadStatus: ThreadStatusPill | null;
707+ orderedProjectThreadKeys: readonly string[];
708+ renderedThreads: readonly SidebarThreadSummary[];
709+ showEmptyThreadState: boolean;
710+ shouldShowThreadPanel: boolean;
711+ isThreadListExpanded: boolean;
712+ projectCwd: string;
713+ activeRouteThreadKey: string | null;
714+ threadJumpLabelByKey: ReadonlyMap<string, string>;
715+ appSettingsConfirmThreadArchive: boolean;
716+ renamingThreadKey: string | null;
717+ renamingTitle: string;
718+ setRenamingTitle: (title: string) => void;
719+ renamingInputRef: React.RefObject<HTMLInputElement | null>;
720+ renamingCommittedRef: React.RefObject<boolean>;
721+ confirmingArchiveThreadKey: string | null;
722+ setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>;
723+ confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>;
724+ attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
725+ handleThreadClick: (
726+ event: React.MouseEvent,
727+ threadRef: ScopedThreadRef,
728+ orderedProjectThreadKeys: readonly string[],
729+ ) => void;
730+ navigateToThread: (threadRef: ScopedThreadRef) => void;
731+ handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
732+ handleThreadContextMenu: (
733+ threadRef: ScopedThreadRef,
734+ position: { x: number; y: number },
735+ ) => Promise<void>;
736+ clearSelection: () => void;
737+ commitRename: (
738+ threadRef: ScopedThreadRef,
739+ newTitle: string,
740+ originalTitle: string,
741+ ) => Promise<void>;
742+ cancelRename: () => void;
743+ attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>;
744+ openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;
745+ expandThreadListForProject: (projectKey: string) => void;
746+ collapseThreadListForProject: (projectKey: string) => void;
548747 }
549748
550−function T3Wordmark() {
551− return (
552− <svg
553− aria-label="T3"
554− className="h-2.5 w-auto shrink-0 text-foreground"
555− viewBox="15.5309 37 94.3941 56.96"
556− xmlns="http://www.w3.org/2000/svg"
557− >
558− <path
559− d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
560− fill="currentColor"
561− />
562− </svg>
563− );
564−}
565−
566−type SortableProjectHandleProps = Pick<
567− ReturnType<typeof useSortable>,
568− "attributes" | "listeners" | "setActivatorNodeRef"
569−>;
749+const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
750+ props: SidebarProjectThreadListProps,
751+) {
752+ const {
753+ projectKey,
754+ projectExpanded,
755+ hasOverflowingThreads,
756+ hiddenThreadStatus,
757+ orderedProjectThreadKeys,
758+ renderedThreads,
759+ showEmptyThreadState,
760+ shouldShowThreadPanel,
761+ isThreadListExpanded,
762+ projectCwd,
763+ activeRouteThreadKey,
764+ threadJumpLabelByKey,
765+ appSettingsConfirmThreadArchive,
766+ renamingThreadKey,
767+ renamingTitle,
768+ setRenamingTitle,
769+ renamingInputRef,
770+ renamingCommittedRef,
771+ confirmingArchiveThreadKey,
772+ setConfirmingArchiveThreadKey,
773+ confirmArchiveButtonRefs,
774+ attachThreadListAutoAnimateRef,
775+ handleThreadClick,
776+ navigateToThread,
777+ handleMultiSelectContextMenu,
778+ handleThreadContextMenu,
779+ clearSelection,
780+ commitRename,
781+ cancelRename,
782+ attemptArchiveThread,
783+ openPrLink,
784+ expandThreadListForProject,
785+ collapseThreadListForProject,
786+ } = props;
787+ const showMoreButtonRender = useMemo(() => <button type="button" />, []);
788+ const showLessButtonRender = useMemo(() => <button type="button" />, []);
570789
571−function ProjectSortMenu({
572− projectSortOrder,
573− threadSortOrder,
574− onProjectSortOrderChange,
575− onThreadSortOrderChange,
576−}: {
577− projectSortOrder: SidebarProjectSortOrder;
578− threadSortOrder: SidebarThreadSortOrder;
579− onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
580− onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
581−}) {
582790 return (
583− <Menu>
584− <Tooltip>
585− <TooltipTrigger
586− render={
587− <MenuTrigger className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" />
588− }
589− >
590− <ArrowUpDownIcon className="size-3.5" />
591− </TooltipTrigger>
592− <TooltipPopup side="right">Sort projects</TooltipPopup>
593− </Tooltip>
594− <MenuPopup align="end" side="bottom" className="min-w-44">
595− <MenuGroup>
596− <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground">
597− Sort projects
791+ <SidebarMenuSub
792+ ref={attachThreadListAutoAnimateRef}
793+ className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0"
794+ >
795+ {shouldShowThreadPanel && showEmptyThreadState ? (
796+ <SidebarMenuSubItem className="w-full" data-thread-selection-safe>
797+ <div
798+ data-thread-selection-safe
799+ className="flex h-6 w-full translate-x-0 items-center px-2 text-left text-[10px] text-muted-foreground/60"
800+ >
801+ <span>No threads yet</span>
598802 </div>
599− <MenuRadioGroup
600− value={projectSortOrder}
601− onValueChange={(value) => {
602− onProjectSortOrderChange(value as SidebarProjectSortOrder);
803+ </SidebarMenuSubItem>
804+ ) : null}
805+ {shouldShowThreadPanel &&
806+ renderedThreads.map((thread) => {
807+ const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
808+ return (
809+ <SidebarThreadRow
810+ key={threadKey}
811+ thread={thread}
812+ projectCwd={projectCwd}
813+ orderedProjectThreadKeys={orderedProjectThreadKeys}
814+ isActive={activeRouteThreadKey === threadKey}
815+ jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null}
816+ appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
817+ renamingThreadKey={renamingThreadKey}
818+ renamingTitle={renamingTitle}
819+ setRenamingTitle={setRenamingTitle}
820+ renamingInputRef={renamingInputRef}
821+ renamingCommittedRef={renamingCommittedRef}
822+ confirmingArchiveThreadKey={confirmingArchiveThreadKey}
823+ setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
824+ confirmArchiveButtonRefs={confirmArchiveButtonRefs}
825+ handleThreadClick={handleThreadClick}
826+ navigateToThread={navigateToThread}
827+ handleMultiSelectContextMenu={handleMultiSelectContextMenu}
828+ handleThreadContextMenu={handleThreadContextMenu}
829+ clearSelection={clearSelection}
830+ commitRename={commitRename}
831+ cancelRename={cancelRename}
832+ attemptArchiveThread={attemptArchiveThread}
833+ openPrLink={openPrLink}
834+ />
835+ );
836+ })}
837+
838+ {projectExpanded && hasOverflowingThreads && !isThreadListExpanded && (
839+ <SidebarMenuSubItem className="w-full">
840+ <SidebarMenuSubButton
841+ render={showMoreButtonRender}
842+ data-thread-selection-safe
843+ size="sm"
844+ className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
845+ onClick={() => {
846+ expandThreadListForProject(projectKey);
603847 }}
604848 >
605− {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map(
606− ([value, label]) => (
607− <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
608− {label}
609− </MenuRadioItem>
610− ),
611− )}
612− </MenuRadioGroup>
613− </MenuGroup>
614− <MenuGroup>
615− <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground">
616− Sort threads
617− </div>
618− <MenuRadioGroup
619− value={threadSortOrder}
620− onValueChange={(value) => {
621− onThreadSortOrderChange(value as SidebarThreadSortOrder);
849+ <span className="flex min-w-0 flex-1 items-center gap-2">
850+ {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />}
851+ <span>Show more</span>
852+ </span>
853+ </SidebarMenuSubButton>
854+ </SidebarMenuSubItem>
855+ )}
856+ {projectExpanded && hasOverflowingThreads && isThreadListExpanded && (
857+ <SidebarMenuSubItem className="w-full">
858+ <SidebarMenuSubButton
859+ render={showLessButtonRender}
860+ data-thread-selection-safe
861+ size="sm"
862+ className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
863+ onClick={() => {
864+ collapseThreadListForProject(projectKey);
622865 }}
623866 >
624− {(
625− Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]>
626− ).map(([value, label]) => (
627− <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
628− {label}
629− </MenuRadioItem>
630− ))}
631− </MenuRadioGroup>
632− </MenuGroup>
633− </MenuPopup>
634− </Menu>
867+ <span>Show less</span>
868+ </SidebarMenuSubButton>
869+ </SidebarMenuSubItem>
870+ )}
871+ </SidebarMenuSub>
635872 );
873+});
874+
875+interface SidebarProjectItemProps {
876+ project: SidebarProjectSnapshot;
877+ isThreadListExpanded: boolean;
878+ activeRouteThreadKey: string | null;
879+ newThreadShortcutLabel: string | null;
880+ handleNewThread: ReturnType<typeof useHandleNewThread>["handleNewThread"];
881+ archiveThread: ReturnType<typeof useThreadActions>["archiveThread"];
882+ deleteThread: ReturnType<typeof useThreadActions>["deleteThread"];
883+ threadJumpLabelByKey: ReadonlyMap<string, string>;
884+ attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
885+ expandThreadListForProject: (projectKey: string) => void;
886+ collapseThreadListForProject: (projectKey: string) => void;
887+ dragInProgressRef: React.RefObject<boolean>;
888+ suppressProjectClickAfterDragRef: React.RefObject<boolean>;
889+ suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
890+ isManualProjectSorting: boolean;
891+ dragHandleProps: SortableProjectHandleProps | null;
636892 }
637893
638−function SortableProjectItem({
639− projectId,
640− disabled = false,
641− children,
642−}: {
643− projectId: ProjectId;
644− disabled?: boolean;
645− children: (handleProps: SortableProjectHandleProps) => ReactNode;
646−}) {
894+const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) {
647895 const {
648− attributes,
649− listeners,
650− setActivatorNodeRef,
651− setNodeRef,
652− transform,
653− transition,
654− isDragging,
655− isOver,
656− } = useSortable({ id: projectId, disabled });
657− return (
658− <li
659− ref={setNodeRef}
660− style={{
661− transform: CSS.Translate.toString(transform),
662− transition,
663− }}
664− className={`group/menu-item relative rounded-md ${
665− isDragging ? "z-20 opacity-80" : ""
666− } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`}
667− data-sidebar="menu-item"
668− data-slot="sidebar-menu-item"
669− >
670− {children({ attributes, listeners, setActivatorNodeRef })}
671− </li>
896+ project,
897+ isThreadListExpanded,
898+ activeRouteThreadKey,
899+ newThreadShortcutLabel,
900+ handleNewThread,
901+ archiveThread,
902+ deleteThread,
903+ threadJumpLabelByKey,
904+ attachThreadListAutoAnimateRef,
905+ expandThreadListForProject,
906+ collapseThreadListForProject,
907+ dragInProgressRef,
908+ suppressProjectClickAfterDragRef,
909+ suppressProjectClickForContextMenuRef,
910+ isManualProjectSorting,
911+ dragHandleProps,
912+ } = props;
913+ const threadSortOrder = useSettings<SidebarThreadSortOrder>(
914+ (settings) => settings.sidebarThreadSortOrder,
672915 );
673−}
674−
675−export default function Sidebar() {
676− const projectIds = useStore((store) => store.projectIds);
677− const projectById = useStore((store) => store.projectById);
678− const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById);
679− const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId);
680− const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore(
681− useShallow((store) => ({
682− projectExpandedById: store.projectExpandedById,
683− projectOrder: store.projectOrder,
684− threadLastVisitedAtById: store.threadLastVisitedAtById,
685− })),
686− );
687− const markThreadUnread = useUiStateStore((store) => store.markThreadUnread);
688− const toggleProject = useUiStateStore((store) => store.toggleProject);
689− const reorderProjects = useUiStateStore((store) => store.reorderProjects);
690− const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
691− const getDraftThreadByProjectId = useComposerDraftStore(
692− (store) => store.getDraftThreadByProjectId,
916+ const appSettingsConfirmThreadDelete = useSettings<boolean>(
917+ (settings) => settings.confirmThreadDelete,
918+ );
919+ const appSettingsConfirmThreadArchive = useSettings<boolean>(
920+ (settings) => settings.confirmThreadArchive,
921+ );
922+ const defaultThreadEnvMode = useSettings<ThreadEnvMode>(
923+ (settings) => settings.defaultThreadEnvMode,
924+ );
925+ const router = useRouter();
926+ const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
927+ const toggleProject = useUiStateStore((state) => state.toggleProject);
928+ const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread);
929+ const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo);
930+ const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
931+ const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection);
932+ const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor);
933+ const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size);
934+ const clearComposerDraftForThread = useComposerDraftStore((state) => state.clearDraftThread);
935+ const getDraftThreadByProjectRef = useComposerDraftStore(
936+ (state) => state.getDraftThreadByProjectRef,
693937 );
694− const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
695938 const clearProjectDraftThreadId = useComposerDraftStore(
696− (store) => store.clearProjectDraftThreadId,
939+ (state) => state.clearProjectDraftThreadId,
697940 );
698− const navigate = useNavigate();
699− const pathname = useLocation({ select: (loc) => loc.pathname });
700− const isOnSettings = pathname.startsWith("/settings");
701− const appSettings = useSettings();
702− const { updateSettings } = useUpdateSettings();
703− const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread();
704− const { archiveThread, deleteThread } = useThreadActions();
705− const routeThreadId = useParams({
706− strict: false,
707− select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
941+ const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
942+ threadId: ThreadId;
943+ }>({
944+ onCopy: (ctx) => {
945+ toastManager.add({
946+ type: "success",
947+ title: "Thread ID copied",
948+ description: ctx.threadId,
949+ });
950+ },
951+ onError: (error) => {
952+ toastManager.add({
953+ type: "error",
954+ title: "Failed to copy thread ID",
955+ description: error instanceof Error ? error.message : "An error occurred.",
956+ });
957+ },
708958 });
709− const keybindings = useServerKeybindings();
710− const [addingProject, setAddingProject] = useState(false);
711− const [newCwd, setNewCwd] = useState("");
712− const [isPickingFolder, setIsPickingFolder] = useState(false);
713− const [isAddingProject, setIsAddingProject] = useState(false);
714− const [addProjectError, setAddProjectError] = useState<string | null>(null);
715− const addProjectInputRef = useRef<HTMLInputElement | null>(null);
716− const [renamingProjectId, setRenamingProjectId] = useState<ProjectId | null>(null);
717− const [renamingProjectTitle, setRenamingProjectTitle] = useState("");
718− const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
719− const [renamingTitle, setRenamingTitle] = useState("");
720− const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);
721− const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
722− ReadonlySet<ProjectId>
723− >(() => new Set());
724− const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
725− const projectRenamingCommittedRef = useRef(false);
726− const projectRenamingInputRef = useRef<HTMLInputElement | null>(null);
727− const renamingCommittedRef = useRef(false);
728− const renamingInputRef = useRef<HTMLInputElement | null>(null);
729− const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>());
730− const dragInProgressRef = useRef(false);
731− const suppressProjectClickAfterDragRef = useRef(false);
732− const suppressProjectClickForContextMenuRef = useRef(false);
733− const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
734− const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds);
735− const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
736− const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
737− const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
738− const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
739− const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
740− const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform);
741− const platform = navigator.platform;
742− const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
743− const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
744− const projects = useMemo(
745− () =>
746− projectIds.flatMap((projectId) => {
747− const project = projectById[projectId];
748− return project ? [project] : [];
749− }),
750− [projectById, projectIds],
751− );
752− const orderedProjects = useMemo(() => {
753− return orderItemsByPreferredIds({
754− items: projects,
755− preferredIds: projectOrder,
756− getId: (project) => project.id,
757− });
758− }, [projectOrder, projects]);
759− const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(
760− () =>
761− orderedProjects.map((project) => ({
762− ...project,
763− expanded: projectExpandedById[project.id] ?? true,
764− })),
765− [orderedProjects, projectExpandedById],
766− );
767− const sidebarThreads = useMemo(() => Object.values(sidebarThreadsById), [sidebarThreadsById]);
768− const projectCwdById = useMemo(
769− () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
770− [projects],
771− );
772− const routeTerminalOpen = routeThreadId
773− ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen
774− : false;
775− const sidebarShortcutLabelOptions = useMemo(
776− () => ({
777− platform,
778− context: {
779− terminalFocus: false,
780− terminalOpen: routeTerminalOpen,
781− },
782− }),
783− [platform, routeTerminalOpen],
784− );
785− const openPrLink = useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => {
959+ const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
960+ path: string;
961+ }>({
962+ onCopy: (ctx) => {
963+ toastManager.add({
964+ type: "success",
965+ title: "Path copied",
966+ description: ctx.path,
967+ });
968+ },
969+ onError: (error) => {
970+ toastManager.add({
971+ type: "error",
972+ title: "Failed to copy path",
973+ description: error instanceof Error ? error.message : "An error occurred.",
974+ });
975+ },
976+ });
977+ const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
786978 event.preventDefault();
787979 event.stopPropagation();
788980
789− const api = readNativeApi();
981+ const api = readLocalApi();
790982 if (!api) {
791983 toastManager.add({
792984 type: "error",
@@ -803,222 +995,495 @@ export default function Sidebar() { · 10 unmodified lines
803995 });
804996 });
805997 }, []);
998+ const sidebarThreads = useStore(
999+ useShallow(
1000+ useMemo(
1001+ () => (state) =>
1002+ selectSidebarThreadsForProjectRef(
1003+ state,
1004+ scopeProjectRef(project.environmentId, project.id),
1005+ ),
1006+ [project.environmentId, project.id],
1007+ ),
1008+ ),
1009+ );
1010+ const sidebarThreadByKey = useMemo(
1011+ () =>
1012+ new Map(
1013+ sidebarThreads.map(
1014+ (thread) =>
1015+ [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
1016+ ),
1017+ ),
1018+ [sidebarThreads],
1019+ );
1020+ const projectThreads = useMemo(
1021+ () =>
1022+ sidebarThreads.filter(
1023+ (thread) =>
1024+ scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)) ===
1025+ project.projectKey,
1026+ ),
1027+ [project.projectKey, sidebarThreads],
1028+ );
1029+ const projectExpanded = useUiStateStore(
1030+ (state) => state.projectExpandedById[project.projectKey] ?? true,
1031+ );
1032+ const threadLastVisitedAts = useUiStateStore(
1033+ useShallow((state) =>
1034+ projectThreads.map(
1035+ (thread) =>
1036+ state.threadLastVisitedAtById[
1037+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))
1038+ ] ?? null,
1039+ ),
1040+ ),
1041+ );
1042+ const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
1043+ const [renamingTitle, setRenamingTitle] = useState("");
1044+ const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null);
1045+ const renamingCommittedRef = useRef(false);
1046+ const renamingInputRef = useRef<HTMLInputElement | null>(null);
1047+ const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>());
1048+
1049+ const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => {
1050+ const lastVisitedAtByThreadKey = new Map(
1051+ projectThreads.map((thread, index) => [
1052+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1053+ threadLastVisitedAts[index] ?? null,
1054+ ]),
1055+ );
1056+ const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => {
1057+ const lastVisitedAt = lastVisitedAtByThreadKey.get(
1058+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1059+ );
1060+ return resolveThreadStatusPill({
1061+ thread: {
1062+ ...thread,
1063+ ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
1064+ },
1065+ });
1066+ };
1067+ const visibleProjectThreads = sortThreadsForSidebar(
1068+ projectThreads.filter((thread) => thread.archivedAt === null),
1069+ threadSortOrder,
1070+ );
1071+ const projectStatus = resolveProjectStatusIndicator(
1072+ visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)),
1073+ );
1074+ return {
1075+ orderedProjectThreadKeys: visibleProjectThreads.map((thread) =>
1076+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1077+ ),
1078+ projectStatus,
1079+ visibleProjectThreads,
1080+ };
1081+ }, [projectThreads, threadLastVisitedAts, threadSortOrder]);
8061082
807− const attemptArchiveThread = useCallback(
808− async (threadId: ThreadId) => {
809− try {
810− await archiveThread(threadId);
811− } catch (error) {
812− toastManager.add({
813− type: "error",
814− title: "Failed to archive thread",
815− description: error instanceof Error ? error.message : "An error occurred.",
816− });
1083+ const pinnedCollapsedThread = useMemo(() => {
1084+ const activeThreadKey = activeRouteThreadKey ?? undefined;
1085+ if (!activeThreadKey || projectExpanded) {
1086+ return null;
1087+ }
1088+ return (
1089+ visibleProjectThreads.find(
1090+ (thread) =>
1091+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey,
1092+ ) ?? null
1093+ );
1094+ }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]);
1095+
1096+ const {
1097+ hasOverflowingThreads,
1098+ hiddenThreadStatus,
1099+ renderedThreads,
1100+ showEmptyThreadState,
1101+ shouldShowThreadPanel,
1102+ } = useMemo(() => {
1103+ const lastVisitedAtByThreadKey = new Map(
1104+ projectThreads.map((thread, index) => [
1105+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1106+ threadLastVisitedAts[index] ?? null,
1107+ ]),
1108+ );
1109+ const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => {
1110+ const lastVisitedAt = lastVisitedAtByThreadKey.get(
1111+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1112+ );
1113+ return resolveThreadStatusPill({
1114+ thread: {
1115+ ...thread,
1116+ ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
1117+ },
1118+ });
1119+ };
1120+ const hasOverflowingThreads = visibleProjectThreads.length > THREAD_PREVIEW_LIMIT;
1121+ const previewThreads =
1122+ isThreadListExpanded || !hasOverflowingThreads
1123+ ? visibleProjectThreads
1124+ : visibleProjectThreads.slice(0, THREAD_PREVIEW_LIMIT);
1125+ const visibleThreadKeys = new Set(
1126+ [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) =>
1127+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1128+ ),
1129+ );
1130+ const renderedThreads = pinnedCollapsedThread
1131+ ? [pinnedCollapsedThread]
1132+ : visibleProjectThreads.filter((thread) =>
1133+ visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1134+ );
1135+ const hiddenThreads = visibleProjectThreads.filter(
1136+ (thread) =>
1137+ !visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1138+ );
1139+ return {
1140+ hasOverflowingThreads,
1141+ hiddenThreadStatus: resolveProjectStatusIndicator(
1142+ hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)),
1143+ ),
1144+ renderedThreads,
1145+ showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0,
1146+ shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null,
1147+ };
1148+ }, [
1149+ isThreadListExpanded,
1150+ pinnedCollapsedThread,
1151+ projectExpanded,
1152+ projectThreads,
1153+ threadLastVisitedAts,
1154+ visibleProjectThreads,
1155+ ]);
1156+
1157+ const handleProjectButtonClick = useCallback(
1158+ (event: React.MouseEvent<HTMLButtonElement>) => {
1159+ if (suppressProjectClickForContextMenuRef.current) {
1160+ suppressProjectClickForContextMenuRef.current = false;
1161+ event.preventDefault();
1162+ event.stopPropagation();
1163+ return;
1164+ }
1165+ if (dragInProgressRef.current) {
1166+ event.preventDefault();
1167+ event.stopPropagation();
1168+ return;
1169+ }
1170+ if (suppressProjectClickAfterDragRef.current) {
1171+ suppressProjectClickAfterDragRef.current = false;
1172+ event.preventDefault();
1173+ event.stopPropagation();
1174+ return;
1175+ }
1176+ if (selectedThreadCount > 0) {
1177+ clearSelection();
8171178 }
1179+ toggleProject(project.projectKey);
8181180 },
819− [archiveThread],
1181+ [
1182+ clearSelection,
1183+ dragInProgressRef,
1184+ project.projectKey,
1185+ selectedThreadCount,
1186+ suppressProjectClickAfterDragRef,
1187+ suppressProjectClickForContextMenuRef,
1188+ toggleProject,
1189+ ],
8201190 );
8211191
822− const focusMostRecentThreadForProject = useCallback(
823− (projectId: ProjectId) => {
824− const latestThread = sortThreadsForSidebar(
825− (threadIdsByProjectId[projectId] ?? [])
826− .map((threadId) => sidebarThreadsById[threadId])
827− .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined)
828− .filter((thread) => thread.archivedAt === null),
829− appSettings.sidebarThreadSortOrder,
830− )[0];
831− if (!latestThread) return;
832−
833− void navigate({
834− to: "/$threadId",
835− params: { threadId: latestThread.id },
836− });
1192+ const handleProjectButtonKeyDown = useCallback(
1193+ (event: React.KeyboardEvent<HTMLButtonElement>) => {
1194+ if (event.key !== "Enter" && event.key !== " ") return;
1195+ event.preventDefault();
1196+ if (dragInProgressRef.current) {
1197+ return;
1198+ }
1199+ toggleProject(project.projectKey);
8371200 },
838− [appSettings.sidebarThreadSortOrder, navigate, sidebarThreadsById, threadIdsByProjectId],
1201+ [dragInProgressRef, project.projectKey, toggleProject],
8391202 );
8401203
841− const addProjectFromPath = useCallback(
842− async (rawCwd: string) => {
843− const cwd = rawCwd.trim();
844− if (!cwd || isAddingProject) return;
845− const api = readNativeApi();
846− if (!api) return;
1204+ const handleProjectButtonPointerDownCapture = useCallback(
1205+ (event: React.PointerEvent<HTMLButtonElement>) => {
1206+ suppressProjectClickForContextMenuRef.current = false;
1207+ if (
1208+ isContextMenuPointerDown({
1209+ button: event.button,
1210+ ctrlKey: event.ctrlKey,
1211+ isMac: isMacPlatform(navigator.platform),
1212+ })
1213+ ) {
1214+ event.stopPropagation();
1215+ }
8471216
848− setIsAddingProject(true);
849− const finishAddingProject = () => {
850− setIsAddingProject(false);
851− setNewCwd("");
852− setAddProjectError(null);
853− setAddingProject(false);
854− };
1217+ suppressProjectClickAfterDragRef.current = false;
1218+ },
1219+ [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef],
1220+ );
8551221
856− const existing = projects.find((project) => project.cwd === cwd);
857− if (existing) {
858− focusMostRecentThreadForProject(existing.id);
859− finishAddingProject();
860− return;
861− }
1222+ const handleProjectButtonContextMenu = useCallback(
1223+ (event: React.MouseEvent<HTMLButtonElement>) => {
1224+ event.preventDefault();
1225+ suppressProjectClickForContextMenuRef.current = true;
1226+ void (async () => {
1227+ const api = readLocalApi();
1228+ if (!api) return;
8621229
863− const projectId = newProjectId();
864− const createdAt = new Date().toISOString();
865− const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
866− try {
867− await api.orchestration.dispatchCommand({
868− type: "project.create",
869− commandId: newCommandId(),
870− projectId,
871− title,
872− workspaceRoot: cwd,
873− defaultModelSelection: {
874− provider: "codex",
875− model: DEFAULT_MODEL_BY_PROVIDER.codex,
1230+ const clicked = await api.contextMenu.show(
1231+ [
1232+ { id: "copy-path", label: "Copy Project Path" },
1233+ { id: "delete", label: "Remove project", destructive: true },
1234+ ],
1235+ {
1236+ x: event.clientX,
1237+ y: event.clientY,
8761238 },
877− createdAt,
878− });
879− await handleNewThread(projectId, {
880− envMode: appSettings.defaultThreadEnvMode,
881− }).catch(() => undefined);
882− } catch (error) {
883− const description =
884− error instanceof Error ? error.message : "An error occurred while adding the project.";
885− setIsAddingProject(false);
886− if (shouldBrowseForProjectImmediately) {
1239+ );
1240+ if (clicked === "copy-path") {
1241+ copyPathToClipboard(project.cwd, { path: project.cwd });
1242+ return;
1243+ }
1244+ if (clicked !== "delete") return;
1245+
1246+ if (projectThreads.length > 0) {
1247+ toastManager.add({
1248+ type: "warning",
1249+ title: "Project is not empty",
1250+ description: "Delete all threads in this project before removing it.",
1251+ });
1252+ return;
1253+ }
1254+
1255+ const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`);
1256+ if (!confirmed) return;
1257+
1258+ try {
1259+ const projectDraftThread = getDraftThreadByProjectRef(
1260+ scopeProjectRef(project.environmentId, project.id),
1261+ );
1262+ if (projectDraftThread) {
1263+ clearComposerDraftForThread(projectDraftThread.draftId);
1264+ }
1265+ clearProjectDraftThreadId(scopeProjectRef(project.environmentId, project.id));
1266+ const projectApi = readEnvironmentApi(project.environmentId);
1267+ if (!projectApi) {
1268+ throw new Error("Project API unavailable.");
1269+ }
1270+ await projectApi.orchestration.dispatchCommand({
1271+ type: "project.delete",
1272+ commandId: newCommandId(),
1273+ projectId: project.id,
1274+ });
1275+ } catch (error) {
1276+ const message =
1277+ error instanceof Error ? error.message : "Unknown error removing project.";
1278+ console.error("Failed to remove project", { projectId: project.id, error });
8871279 toastManager.add({
8881280 type: "error",
889− title: "Failed to add project",
890− description,
1281+ title: `Failed to remove "${project.name}"`,
1282+ description: message,
8911283 });
892− } else {
893− setAddProjectError(description);
8941284 }
895− return;
896− }
897− finishAddingProject();
1285+ })();
8981286 },
8991287 [
900− focusMostRecentThreadForProject,
901− handleNewThread,
902− isAddingProject,
903− projects,
904− shouldBrowseForProjectImmediately,
905− appSettings.defaultThreadEnvMode,
1288+ clearComposerDraftForThread,
1289+ clearProjectDraftThreadId,
1290+ copyPathToClipboard,
1291+ getDraftThreadByProjectRef,
1292+ project.cwd,
1293+ project.environmentId,
1294+ project.id,
1295+ project.name,
1296+ projectThreads.length,
1297+ suppressProjectClickForContextMenuRef,
9061298 ],
9071299 );
9081300
909− const handleAddProject = () => {
910− void addProjectFromPath(newCwd);
911− };
1301+ const navigateToThread = useCallback(
1302+ (threadRef: ScopedThreadRef) => {
1303+ if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
1304+ clearSelection();
1305+ }
1306+ setSelectionAnchor(scopedThreadKey(threadRef));
1307+ void router.navigate({
1308+ to: "/$environmentId/$threadId",
1309+ params: buildThreadRouteParams(threadRef),
1310+ });
1311+ },
1312+ [clearSelection, router, setSelectionAnchor],
1313+ );
9121314
913− const canAddProject = newCwd.trim().length > 0 && !isAddingProject;
1315+ const handleThreadClick = useCallback(
1316+ (
1317+ event: React.MouseEvent,
1318+ threadRef: ScopedThreadRef,
1319+ orderedProjectThreadKeys: readonly string[],
1320+ ) => {
1321+ const isMac = isMacPlatform(navigator.platform);
1322+ const isModClick = isMac ? event.metaKey : event.ctrlKey;
1323+ const isShiftClick = event.shiftKey;
1324+ const threadKey = scopedThreadKey(threadRef);
1325+ const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size;
9141326
915− const handlePickFolder = async () => {
916− const api = readNativeApi();
917− if (!api || isPickingFolder) return;
918− setIsPickingFolder(true);
919− let pickedPath: string | null = null;
920− try {
921− pickedPath = await api.dialogs.pickFolder();
922− } catch {
923− // Ignore picker failures and leave the current thread selection unchanged.
924− }
925− if (pickedPath) {
926− await addProjectFromPath(pickedPath);
927− } else if (!shouldBrowseForProjectImmediately) {
928− addProjectInputRef.current?.focus();
929− }
930− setIsPickingFolder(false);
931− };
932−
933− const handleStartAddProject = () => {
934− setAddProjectError(null);
935− if (shouldBrowseForProjectImmediately) {
936− void handlePickFolder();
937− return;
938− }
939− setAddingProject((prev) => !prev);
940− };
941−
942− const cancelRename = useCallback(() => {
943− setRenamingThreadId(null);
944− renamingInputRef.current = null;
945− }, []);
1327+ if (isModClick) {
1328+ event.preventDefault();
1329+ toggleThreadSelection(threadKey);
1330+ return;
1331+ }
9461332
947− const handleRenamingInputMount = useCallback((element: HTMLInputElement | null) => {
948− if (element && renamingInputRef.current !== element) {
949− renamingInputRef.current = element;
950− element.focus();
951− element.select();
952− return;
953− }
954− if (element === null && renamingInputRef.current !== null) {
955− renamingInputRef.current = null;
956− }
957− }, []);
1333+ if (isShiftClick) {
1334+ event.preventDefault();
1335+ rangeSelectTo(threadKey, orderedProjectThreadKeys);
1336+ return;
1337+ }
9581338
959− const hasRenameCommitted = useCallback(() => renamingCommittedRef.current, []);
960− const markRenameCommitted = useCallback(() => {
961− renamingCommittedRef.current = true;
962− }, []);
1339+ if (currentSelectionCount > 0) {
1340+ clearSelection();
1341+ }
1342+ setSelectionAnchor(threadKey);
1343+ void router.navigate({
1344+ to: "/$environmentId/$threadId",
1345+ params: buildThreadRouteParams(threadRef),
1346+ });
1347+ },
1348+ [clearSelection, rangeSelectTo, router, setSelectionAnchor, toggleThreadSelection],
1349+ );
9631350
964− const cancelProjectRename = useCallback(() => {
965− setRenamingProjectId(null);
966− projectRenamingInputRef.current = null;
967− }, []);
1351+ const handleMultiSelectContextMenu = useCallback(
1352+ async (position: { x: number; y: number }) => {
1353+ const api = readLocalApi();
1354+ if (!api) return;
1355+ const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
1356+ if (threadKeys.length === 0) return;
1357+ const count = threadKeys.length;
9681358
969− const commitRename = useCallback(
970− async (threadId: ThreadId, newTitle: string, originalTitle: string) => {
971− const finishRename = () => {
972− setRenamingThreadId((current) => {
973− if (current !== threadId) return current;
974− renamingInputRef.current = null;
975− return null;
976− });
977− };
1359+ const clicked = await api.contextMenu.show(
1360+ [
1361+ { id: "mark-unread", label: `Mark unread (${count})` },
1362+ { id: "delete", label: `Delete (${count})`, destructive: true },
1363+ ],
1364+ position,
1365+ );
9781366
979− const trimmed = newTitle.trim();
980− if (trimmed.length === 0) {
981− toastManager.add({
982− type: "warning",
983− title: "Thread title cannot be empty",
984− });
985− finishRename();
1367+ if (clicked === "mark-unread") {
1368+ for (const threadKey of threadKeys) {
1369+ const thread = sidebarThreadByKey.get(threadKey);
1370+ markThreadUnread(threadKey, thread?.latestTurn?.completedAt);
1371+ }
1372+ clearSelection();
9861373 return;
9871374 }
988− if (trimmed === originalTitle) {
989− finishRename();
990− return;
1375+
1376+ if (clicked !== "delete") return;
1377+
1378+ if (appSettingsConfirmThreadDelete) {
1379+ const confirmed = await api.dialogs.confirm(
1380+ [
1381+ `Delete ${count} thread${count === 1 ? "" : "s"}?`,
1382+ "This permanently clears conversation history for these threads.",
1383+ ].join("\n"),
1384+ );
1385+ if (!confirmed) return;
9911386 }
992− const api = readNativeApi();
993− if (!api) {
994− finishRename();
995− return;
1387+
1388+ const deletedThreadKeys = new Set(threadKeys);
1389+ for (const threadKey of threadKeys) {
1390+ const thread = sidebarThreadByKey.get(threadKey);
1391+ if (!thread) continue;
1392+ await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
1393+ deletedThreadKeys,
1394+ });
9961395 }
1396+ removeFromSelection(threadKeys);
1397+ },
1398+ [
1399+ appSettingsConfirmThreadDelete,
1400+ clearSelection,
1401+ deleteThread,
1402+ markThreadUnread,
1403+ removeFromSelection,
1404+ sidebarThreadByKey,
1405+ ],
1406+ );
1407+
1408+ const handleCreateThreadClick = useCallback(
1409+ (event: React.MouseEvent<HTMLButtonElement>) => {
1410+ event.preventDefault();
1411+ event.stopPropagation();
1412+ const currentRouteParams =
1413+ router.state.matches[router.state.matches.length - 1]?.params ?? {};
1414+ const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams);
1415+ const currentActiveThread =
1416+ currentRouteTarget?.kind === "server"
1417+ ? (selectThreadByRef(useStore.getState(), currentRouteTarget.threadRef) ?? null)
1418+ : null;
1419+ const draftStore = useComposerDraftStore.getState();
1420+ const currentActiveDraftThread =
1421+ currentRouteTarget?.kind === "server"
1422+ ? (draftStore.getDraftThread(currentRouteTarget.threadRef) ?? null)
1423+ : currentRouteTarget?.kind === "draft"
1424+ ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null)
1425+ : null;
1426+ const seedContext = resolveSidebarNewThreadSeedContext({
1427+ projectId: project.id,
1428+ defaultEnvMode: resolveSidebarNewThreadEnvMode({
1429+ defaultEnvMode: defaultThreadEnvMode,
1430+ }),
1431+ activeThread:
1432+ currentActiveThread && currentActiveThread.projectId === project.id
1433+ ? {
1434+ projectId: currentActiveThread.projectId,
1435+ branch: currentActiveThread.branch,
1436+ worktreePath: currentActiveThread.worktreePath,
1437+ }
1438+ : null,
1439+ activeDraftThread:
1440+ currentActiveDraftThread && currentActiveDraftThread.projectId === project.id
1441+ ? {
1442+ projectId: currentActiveDraftThread.projectId,
1443+ branch: currentActiveDraftThread.branch,
1444+ worktreePath: currentActiveDraftThread.worktreePath,
1445+ envMode: currentActiveDraftThread.envMode,
1446+ }
1447+ : null,
1448+ });
1449+ void handleNewThread(scopeProjectRef(project.environmentId, project.id), {
1450+ ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}),
1451+ ...(seedContext.worktreePath !== undefined
1452+ ? { worktreePath: seedContext.worktreePath }
1453+ : {}),
1454+ envMode: seedContext.envMode,
1455+ });
1456+ },
1457+ [defaultThreadEnvMode, handleNewThread, project.environmentId, project.id, router],
1458+ );
1459+
1460+ const attemptArchiveThread = useCallback(
1461+ async (threadRef: ScopedThreadRef) => {
9971462 try {
998− await api.orchestration.dispatchCommand({
999− type: "thread.meta.update",
1000− commandId: newCommandId(),
1001− threadId,
1002− title: trimmed,
1003− });
1463+ await archiveThread(threadRef);
10041464 } catch (error) {
10051465 toastManager.add({
10061466 type: "error",
1007− title: "Failed to rename thread",
1467+ title: "Failed to archive thread",
10081468 description: error instanceof Error ? error.message : "An error occurred.",
10091469 });
10101470 }
1011− finishRename();
10121471 },
1013− [],
1472+ [archiveThread],
10141473 );
10151474
1016− const commitProjectRename = useCallback(
1017− async (projectId: ProjectId, newTitle: string, originalTitle: string) => {
1475+ const cancelRename = useCallback(() => {
1476+ setRenamingThreadKey(null);
1477+ renamingInputRef.current = null;
1478+ }, []);
1479+
1480+ const commitRename = useCallback(
1481+ async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => {
1482+ const threadKey = scopedThreadKey(threadRef);
10181483 const finishRename = () => {
1019− setRenamingProjectId((current) => {
1020− if (current !== projectId) return current;
1021− projectRenamingInputRef.current = null;
1484+ setRenamingThreadKey((current) => {
1485+ if (current !== threadKey) return current;
1486+ renamingInputRef.current = null;
10221487 return null;
10231488 });
10241489 };
@@ -1027,7 +1492,7 @@ export default function Sidebar() { · 2 unmodified lines
10271492 if (trimmed.length === 0) {
10281493 toastManager.add({
10291494 type: "warning",
1030− title: "Project title cannot be empty",
1495+ title: "Thread title cannot be empty",
10311496 });
10321497 finishRename();
10331498 return;
@@ -1036,22 +1501,22 @@ export default function Sidebar() { · 2 unmodified lines
10361501 finishRename();
10371502 return;
10381503 }
1039− const api = readNativeApi();
1504+ const api = readEnvironmentApi(threadRef.environmentId);
10401505 if (!api) {
10411506 finishRename();
10421507 return;
10431508 }
10441509 try {
10451510 await api.orchestration.dispatchCommand({
1046− type: "project.meta.update",
1511+ type: "thread.meta.update",
10471512 commandId: newCommandId(),
1048− projectId,
1513+ threadId: threadRef.threadId,
10491514 title: trimmed,
10501515 });
10511516 } catch (error) {
10521517 toastManager.add({
10531518 type: "error",
1054− title: "Failed to rename project",
1519+ title: "Failed to rename thread",
10551520 description: error instanceof Error ? error.message : "An error occurred.",
10561521 });
10571522 }
@@ -1060,50 +1525,19 @@ export default function Sidebar() { · 2 unmodified lines
10601525 [],
10611526 );
10621527
1063− const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
1064− threadId: ThreadId;
1065− }>({
1066− onCopy: (ctx) => {
1067− toastManager.add({
1068− type: "success",
1069− title: "Thread ID copied",
1070− description: ctx.threadId,
1071− });
1072− },
1073− onError: (error) => {
1074− toastManager.add({
1075− type: "error",
1076− title: "Failed to copy thread ID",
1077− description: error instanceof Error ? error.message : "An error occurred.",
1078− });
1079− },
1080− });
1081− const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
1082− path: string;
1083− }>({
1084− onCopy: (ctx) => {
1085− toastManager.add({
1086− type: "success",
1087− title: "Path copied",
1088− description: ctx.path,
1089− });
1090− },
1091− onError: (error) => {
1092− toastManager.add({
1093− type: "error",
1094− title: "Failed to copy path",
1095− description: error instanceof Error ? error.message : "An error occurred.",
1096− });
1097− },
1098− });
10991528 const handleThreadContextMenu = useCallback(
1100− async (threadId: ThreadId, position: { x: number; y: number }) => {
1101− const api = readNativeApi();
1529+ async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
1530+ const api = readLocalApi();
11021531 if (!api) return;
1103− const thread = sidebarThreadsById[threadId];
1532+ const threadKey = scopedThreadKey(threadRef);
1533+ const thread =
1534+ projectThreads.find(
1535+ (projectThread) =>
1536+ projectThread.environmentId === threadRef.environmentId &&
1537+ projectThread.id === threadRef.threadId,
1538+ ) ?? null;
11041539 if (!thread) return;
1105− const threadWorkspacePath =
1106− thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null;
1540+ const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null;
11071541 const clicked = await api.contextMenu.show(
11081542 [
11091543 { id: "rename", label: "Rename thread" },
@@ -1116,16 +1550,14 @@ export default function Sidebar() { · 6 unmodified lines
11161550 );
11171551
11181552 if (clicked === "rename") {
1119− setRenamingProjectId(null);
1120− projectRenamingInputRef.current = null;
1121− setRenamingThreadId(threadId);
1553+ setRenamingThreadKey(threadKey);
11221554 setRenamingTitle(thread.title);
11231555 renamingCommittedRef.current = false;
11241556 return;
11251557 }
11261558
11271559 if (clicked === "mark-unread") {
1128− markThreadUnread(threadId, thread.latestTurn?.completedAt);
1560+ markThreadUnread(threadKey, thread.latestTurn?.completedAt);
11291561 return;
11301562 }
11311563 if (clicked === "copy-path") {
@@ -1141,11 +1573,11 @@ export default function Sidebar() { · 9 unmodified lines
11411573 return;
11421574 }
11431575 if (clicked === "copy-thread-id") {
1144− copyThreadIdToClipboard(threadId, { threadId });
1576+ copyThreadIdToClipboard(thread.id, { threadId: thread.id });
11451577 return;
11461578 }
11471579 if (clicked !== "delete") return;
1148− if (appSettings.confirmThreadDelete) {
1580+ if (appSettingsConfirmThreadDelete) {
11491581 const confirmed = await api.dialogs.confirm(
11501582 [
11511583 `Delete thread "${thread.title}"?`,
@@ -1156,198 +1588,903 @@ export default function Sidebar() { · 4 unmodified lines
11561588 return;
11571589 }
11581590 }
1159− await deleteThread(threadId);
1591+ await deleteThread(threadRef);
11601592 },
11611593 [
1162− appSettings.confirmThreadDelete,
1594+ appSettingsConfirmThreadDelete,
11631595 copyPathToClipboard,
11641596 copyThreadIdToClipboard,
11651597 deleteThread,
11661598 markThreadUnread,
1167− projectCwdById,
1168− sidebarThreadsById,
1599+ project.cwd,
1600+ projectThreads,
11691601 ],
11701602 );
11711603
1172− const handleMultiSelectContextMenu = useCallback(
1173− async (position: { x: number; y: number }) => {
1174− const api = readNativeApi();
1175− if (!api) return;
1176− const ids = [...selectedThreadIds];
1177− if (ids.length === 0) return;
1178− const count = ids.length;
1179−
1180− const clicked = await api.contextMenu.show(
1181− [
1182− { id: "mark-unread", label: `Mark unread (${count})` },
1183− { id: "delete", label: `Delete (${count})`, destructive: true },
1184− ],
1185− position,
1186− );
1187−
1188− if (clicked === "mark-unread") {
1189− for (const id of ids) {
1190− const thread = sidebarThreadsById[id];
1191− markThreadUnread(id, thread?.latestTurn?.completedAt);
1192− }
1193− clearSelection();
1194− return;
1195− }
1196−
1197− if (clicked !== "delete") return;
1198−
1199− if (appSettings.confirmThreadDelete) {
1200− const confirmed = await api.dialogs.confirm(
1201− [
1202− `Delete ${count} thread${count === 1 ? "" : "s"}?`,
1203− "This permanently clears conversation history for these threads.",
1204− ].join("\n"),
1205− );
1206− if (!confirmed) return;
1207− }
1208−
1209− const deletedIds = new Set<ThreadId>(ids);
1210− for (const id of ids) {
1211− await deleteThread(id, { deletedThreadIds: deletedIds });
1212− }
1213− removeFromSelection(ids);
1214− },
1215− [
1216− appSettings.confirmThreadDelete,
1217− clearSelection,
1218− deleteThread,
1219− markThreadUnread,
1220− removeFromSelection,
1221− selectedThreadIds,
1222− sidebarThreadsById,
1223− ],
1604+ return (
1605+ <>
1606+ <div className="group/project-header relative">
1607+ <SidebarMenuButton
1608+ ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
1609+ size="sm"
1610+ className={`gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${
1611+ isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
1612+ }`}
1613+ {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
1614+ {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
1615+ onPointerDownCapture={handleProjectButtonPointerDownCapture}
1616+ onClick={handleProjectButtonClick}
1617+ onKeyDown={handleProjectButtonKeyDown}
1618+ onContextMenu={handleProjectButtonContextMenu}
1619+ >
1620+ {!projectExpanded && projectStatus ? (
1621+ <span
1622+ aria-hidden="true"
1623+ title={projectStatus.label}
1624+ className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
1625+ >
1626+ <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
1627+ <span
1628+ className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
1629+ projectStatus.pulse ? "animate-pulse" : ""
1630+ }`}
1631+ />
1632+ </span>
1633+ <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
1634+ </span>
1635+ ) : (
1636+ <ChevronRightIcon
1637+ className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1638+ projectExpanded ? "rotate-90" : ""
1639+ }`}
1640+ />
1641+ )}
1642+ <ProjectFavicon cwd={project.cwd} />
1643+ <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1644+ {project.name}
1645+ </span>
1646+ </SidebarMenuButton>
1647+ <Tooltip>
1648+ <TooltipTrigger
1649+ render={
1650+ <SidebarMenuAction
1651+ render={
1652+ <button
1653+ type="button"
1654+ aria-label={`Create new thread in ${project.name}`}
1655+ data-testid="new-thread-button"
1656+ />
1657+ }
1658+ showOnHover
1659+ className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1660+ onClick={handleCreateThreadClick}
1661+ >
1662+ <SquarePenIcon className="size-3.5" />
1663+ </SidebarMenuAction>
1664+ }
1665+ />
1666+ <TooltipPopup side="top">
1667+ {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
1668+ </TooltipPopup>
1669+ </Tooltip>
1670+ </div>
1671+
1672+ <SidebarProjectThreadList
1673+ projectKey={project.projectKey}
1674+ projectExpanded={projectExpanded}
1675+ hasOverflowingThreads={hasOverflowingThreads}
1676+ hiddenThreadStatus={hiddenThreadStatus}
1677+ orderedProjectThreadKeys={orderedProjectThreadKeys}
1678+ renderedThreads={renderedThreads}
1679+ showEmptyThreadState={showEmptyThreadState}
1680+ shouldShowThreadPanel={shouldShowThreadPanel}
1681+ isThreadListExpanded={isThreadListExpanded}
1682+ projectCwd={project.cwd}
1683+ activeRouteThreadKey={activeRouteThreadKey}
1684+ threadJumpLabelByKey={threadJumpLabelByKey}
1685+ appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
1686+ renamingThreadKey={renamingThreadKey}
1687+ renamingTitle={renamingTitle}
1688+ setRenamingTitle={setRenamingTitle}
1689+ renamingInputRef={renamingInputRef}
1690+ renamingCommittedRef={renamingCommittedRef}
1691+ confirmingArchiveThreadKey={confirmingArchiveThreadKey}
1692+ setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
1693+ confirmArchiveButtonRefs={confirmArchiveButtonRefs}
1694+ attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
1695+ handleThreadClick={handleThreadClick}
1696+ navigateToThread={navigateToThread}
1697+ handleMultiSelectContextMenu={handleMultiSelectContextMenu}
1698+ handleThreadContextMenu={handleThreadContextMenu}
1699+ clearSelection={clearSelection}
1700+ commitRename={commitRename}
1701+ cancelRename={cancelRename}
1702+ attemptArchiveThread={attemptArchiveThread}
1703+ openPrLink={openPrLink}
1704+ expandThreadListForProject={expandThreadListForProject}
1705+ collapseThreadListForProject={collapseThreadListForProject}
1706+ />
1707+ </>
12241708 );
1709+});
12251710
1226− const handleThreadClick = useCallback(
1227− (event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => {
1228− const isMac = isMacPlatform(navigator.platform);
1229− const isModClick = isMac ? event.metaKey : event.ctrlKey;
1230− const isShiftClick = event.shiftKey;
1231−
1232− if (isModClick) {
1233− event.preventDefault();
1234− toggleThreadSelection(threadId);
1235− return;
1236− }
1237−
1238− if (isShiftClick) {
1239− event.preventDefault();
1240− rangeSelectTo(threadId, orderedProjectThreadIds);
1241− return;
1242− }
1243−
1244− // Plain click — clear selection, set anchor for future shift-clicks, and navigate
1245− if (selectedThreadIds.size > 0) {
1246− clearSelection();
1247− }
1248− setSelectionAnchor(threadId);
1249− void navigate({
1250− to: "/$threadId",
1251− params: { threadId },
1252− });
1253− },
1254− [
1255− clearSelection,
1256− navigate,
1257− rangeSelectTo,
1258− selectedThreadIds.size,
1259− setSelectionAnchor,
1260− toggleThreadSelection,
1261− ],
1711+const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) {
1712+ return (
1713+ <SidebarMenuItem className="rounded-md">
1714+ <SidebarProjectItem {...props} />
1715+ </SidebarMenuItem>
12621716 );
1717+});
12631718
1264− const navigateToThread = useCallback(
1265− (threadId: ThreadId) => {
1266− if (selectedThreadIds.size > 0) {
1267− clearSelection();
1268− }
1269− setSelectionAnchor(threadId);
1270− void navigate({
1271− to: "/$threadId",
1272− params: { threadId },
1273− });
1274− },
1275− [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor],
1719+function T3Wordmark() {
1720+ return (
1721+ <svg
1722+ aria-label="T3"
1723+ className="h-2.5 w-auto shrink-0 text-foreground"
1724+ viewBox="15.5309 37 94.3941 56.96"
1725+ xmlns="http://www.w3.org/2000/svg"
1726+ >
1727+ <path
1728+ d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
1729+ fill="currentColor"
1730+ />
1731+ </svg>
12761732 );
1733+}
12771734
1278− const handleProjectContextMenu = useCallback(
1279− async (projectId: ProjectId, position: { x: number; y: number }) => {
1280− const api = readNativeApi();
1281− if (!api) return;
1282− const project = projects.find((entry) => entry.id === projectId);
1283− if (!project) return;
1735+type SortableProjectHandleProps = Pick<
1736+ ReturnType<typeof useSortable>,
1737+ "attributes" | "listeners" | "setActivatorNodeRef"
1738+>;
12841739
1285− const clicked = await api.contextMenu.show(
1286− [
1287− { id: "rename", label: "Rename project" },
1288− { id: "copy-path", label: "Copy Project Path" },
1289− { id: "delete", label: "Remove project", destructive: true },
1290− ],
1291− position,
1292− );
1293− if (clicked === "rename") {
1294− setRenamingThreadId(null);
1295− renamingInputRef.current = null;
1296− setRenamingProjectId(projectId);
1297− setRenamingProjectTitle(project.name);
1298− projectRenamingCommittedRef.current = false;
1299− return;
1740+function ProjectSortMenu({
1741+ projectSortOrder,
1742+ threadSortOrder,
1743+ onProjectSortOrderChange,
1744+ onThreadSortOrderChange,
1745+}: {
1746+ projectSortOrder: SidebarProjectSortOrder;
1747+ threadSortOrder: SidebarThreadSortOrder;
1748+ onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
1749+ onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
1750+}) {
1751+ return (
1752+ <Menu>
1753+ <Tooltip>
1754+ <TooltipTrigger
1755+ render={
1756+ <MenuTrigger className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" />
1757+ }
1758+ >
1759+ <ArrowUpDownIcon className="size-3.5" />
1760+ </TooltipTrigger>
1761+ <TooltipPopup side="right">Sort projects</TooltipPopup>
1762+ </Tooltip>
1763+ <MenuPopup align="end" side="bottom" className="min-w-44">
1764+ <MenuGroup>
1765+ <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground">
1766+ Sort projects
1767+ </div>
1768+ <MenuRadioGroup
1769+ value={projectSortOrder}
1770+ onValueChange={(value) => {
1771+ onProjectSortOrderChange(value as SidebarProjectSortOrder);
1772+ }}
1773+ >
1774+ {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map(
1775+ ([value, label]) => (
1776+ <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
1777+ {label}
1778+ </MenuRadioItem>
1779+ ),
1780+ )}
1781+ </MenuRadioGroup>
1782+ </MenuGroup>
1783+ <MenuGroup>
1784+ <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground">
1785+ Sort threads
1786+ </div>
1787+ <MenuRadioGroup
1788+ value={threadSortOrder}
1789+ onValueChange={(value) => {
1790+ onThreadSortOrderChange(value as SidebarThreadSortOrder);
1791+ }}
1792+ >
1793+ {(
1794+ Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]>
1795+ ).map(([value, label]) => (
1796+ <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
1797+ {label}
1798+ </MenuRadioItem>
1799+ ))}
1800+ </MenuRadioGroup>
1801+ </MenuGroup>
1802+ </MenuPopup>
1803+ </Menu>
1804+ );
1805+}
1806+
1807+function SortableProjectItem({
1808+ projectId,
1809+ disabled = false,
1810+ children,
1811+}: {
1812+ projectId: string;
1813+ disabled?: boolean;
1814+ children: (handleProps: SortableProjectHandleProps) => React.ReactNode;
1815+}) {
1816+ const {
1817+ attributes,
1818+ listeners,
1819+ setActivatorNodeRef,
1820+ setNodeRef,
1821+ transform,
1822+ transition,
1823+ isDragging,
1824+ isOver,
1825+ } = useSortable({ id: projectId, disabled });
1826+ return (
1827+ <li
1828+ ref={setNodeRef}
1829+ style={{
1830+ transform: CSS.Translate.toString(transform),
1831+ transition,
1832+ }}
1833+ className={`group/menu-item relative rounded-md ${
1834+ isDragging ? "z-20 opacity-80" : ""
1835+ } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`}
1836+ data-sidebar="menu-item"
1837+ data-slot="sidebar-menu-item"
1838+ >
1839+ {children({ attributes, listeners, setActivatorNodeRef })}
1840+ </li>
1841+ );
1842+}
1843+
1844+const SidebarChromeHeader = memo(function SidebarChromeHeader({
1845+ isElectron,
1846+}: {
1847+ isElectron: boolean;
1848+}) {
1849+ const wordmark = (
1850+ <div className="flex items-center gap-2">
1851+ <SidebarTrigger className="shrink-0 md:hidden" />
1852+ <Tooltip>
1853+ <TooltipTrigger
1854+ render={
1855+ <Link
1856+ aria-label="Go to threads"
1857+ className="ml-1 flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md outline-hidden ring-ring transition-colors hover:text-foreground focus-visible:ring-2"
1858+ to="/"
1859+ >
1860+ <T3Wordmark />
1861+ <span className="truncate text-sm font-medium tracking-tight text-muted-foreground">
1862+ Code
1863+ </span>
1864+ <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground/60">
1865+ {APP_STAGE_LABEL}
1866+ </span>
1867+ </Link>
1868+ }
1869+ />
1870+ <TooltipPopup side="bottom" sideOffset={2}>
1871+ Version {APP_VERSION}
1872+ </TooltipPopup>
1873+ </Tooltip>
1874+ </div>
1875+ );
1876+
1877+ return isElectron ? (
1878+ <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
1879+ {wordmark}
1880+ </SidebarHeader>
1881+ ) : (
1882+ <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">{wordmark}</SidebarHeader>
1883+ );
1884+});
1885+
1886+const SidebarChromeFooter = memo(function SidebarChromeFooter() {
1887+ const navigate = useNavigate();
1888+ const handleSettingsClick = useCallback(() => {
1889+ void navigate({ to: "/settings" });
1890+ }, [navigate]);
1891+
1892+ return (
1893+ <SidebarFooter className="p-2">
1894+ <SidebarUpdatePill />
1895+ <SidebarMenu>
1896+ <SidebarMenuItem>
1897+ <SidebarMenuButton
1898+ size="sm"
1899+ className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
1900+ onClick={handleSettingsClick}
1901+ >
1902+ <SettingsIcon className="size-3.5" />
1903+ <span className="text-xs">Settings</span>
1904+ </SidebarMenuButton>
1905+ </SidebarMenuItem>
1906+ </SidebarMenu>
1907+ </SidebarFooter>
1908+ );
1909+});
1910+
1911+interface SidebarProjectsContentProps {
1912+ showArm64IntelBuildWarning: boolean;
1913+ arm64IntelBuildWarningDescription: string | null;
1914+ desktopUpdateButtonAction: "download" | "install" | "none";
1915+ desktopUpdateButtonDisabled: boolean;
1916+ handleDesktopUpdateButtonClick: () => void;
1917+ projectSortOrder: SidebarProjectSortOrder;
1918+ threadSortOrder: SidebarThreadSortOrder;
1919+ updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"];
1920+ shouldShowProjectPathEntry: boolean;
1921+ handleStartAddProject: () => void;
1922+ isElectron: boolean;
1923+ isPickingFolder: boolean;
1924+ isAddingProject: boolean;
1925+ handlePickFolder: () => Promise<void>;
1926+ addProjectInputRef: React.RefObject<HTMLInputElement | null>;
1927+ addProjectError: string | null;
1928+ newCwd: string;
1929+ setNewCwd: React.Dispatch<React.SetStateAction<string>>;
1930+ setAddProjectError: React.Dispatch<React.SetStateAction<string | null>>;
1931+ handleAddProject: () => void;
1932+ setAddingProject: React.Dispatch<React.SetStateAction<boolean>>;
1933+ canAddProject: boolean;
1934+ isManualProjectSorting: boolean;
1935+ projectDnDSensors: ReturnType<typeof useSensors>;
1936+ projectCollisionDetection: CollisionDetection;
1937+ handleProjectDragStart: (event: DragStartEvent) => void;
1938+ handleProjectDragEnd: (event: DragEndEvent) => void;
1939+ handleProjectDragCancel: (event: DragCancelEvent) => void;
1940+ handleNewThread: ReturnType<typeof useHandleNewThread>["handleNewThread"];
1941+ archiveThread: ReturnType<typeof useThreadActions>["archiveThread"];
1942+ deleteThread: ReturnType<typeof useThreadActions>["deleteThread"];
1943+ sortedProjects: readonly SidebarProjectSnapshot[];
1944+ expandedThreadListsByProject: ReadonlySet<string>;
1945+ activeRouteProjectKey: string | null;
1946+ routeThreadKey: string | null;
1947+ newThreadShortcutLabel: string | null;
1948+ threadJumpLabelByKey: ReadonlyMap<string, string>;
1949+ attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
1950+ expandThreadListForProject: (projectKey: string) => void;
1951+ collapseThreadListForProject: (projectKey: string) => void;
1952+ dragInProgressRef: React.RefObject<boolean>;
1953+ suppressProjectClickAfterDragRef: React.RefObject<boolean>;
1954+ suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
1955+ attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void;
1956+ projectsLength: number;
1957+}
1958+
1959+const SidebarProjectsContent = memo(function SidebarProjectsContent(
1960+ props: SidebarProjectsContentProps,
1961+) {
1962+ const {
1963+ showArm64IntelBuildWarning,
1964+ arm64IntelBuildWarningDescription,
1965+ desktopUpdateButtonAction,
1966+ desktopUpdateButtonDisabled,
1967+ handleDesktopUpdateButtonClick,
1968+ projectSortOrder,
1969+ threadSortOrder,
1970+ updateSettings,
1971+ shouldShowProjectPathEntry,
1972+ handleStartAddProject,
1973+ isElectron,
1974+ isPickingFolder,
1975+ isAddingProject,
1976+ handlePickFolder,
1977+ addProjectInputRef,
1978+ addProjectError,
1979+ newCwd,
1980+ setNewCwd,
1981+ setAddProjectError,
1982+ handleAddProject,
1983+ setAddingProject,
1984+ canAddProject,
1985+ isManualProjectSorting,
1986+ projectDnDSensors,
1987+ projectCollisionDetection,
1988+ handleProjectDragStart,
1989+ handleProjectDragEnd,
1990+ handleProjectDragCancel,
1991+ handleNewThread,
1992+ archiveThread,
1993+ deleteThread,
1994+ sortedProjects,
1995+ expandedThreadListsByProject,
1996+ activeRouteProjectKey,
1997+ routeThreadKey,
1998+ newThreadShortcutLabel,
1999+ threadJumpLabelByKey,
2000+ attachThreadListAutoAnimateRef,
2001+ expandThreadListForProject,
2002+ collapseThreadListForProject,
2003+ dragInProgressRef,
2004+ suppressProjectClickAfterDragRef,
2005+ suppressProjectClickForContextMenuRef,
2006+ attachProjectListAutoAnimateRef,
2007+ projectsLength,
2008+ } = props;
2009+
2010+ const handleProjectSortOrderChange = useCallback(
2011+ (sortOrder: SidebarProjectSortOrder) => {
2012+ updateSettings({ sidebarProjectSortOrder: sortOrder });
2013+ },
2014+ [updateSettings],
2015+ );
2016+ const handleThreadSortOrderChange = useCallback(
2017+ (sortOrder: SidebarThreadSortOrder) => {
2018+ updateSettings({ sidebarThreadSortOrder: sortOrder });
2019+ },
2020+ [updateSettings],
2021+ );
2022+ const handleAddProjectInputChange = useCallback(
2023+ (event: React.ChangeEvent<HTMLInputElement>) => {
2024+ setNewCwd(event.target.value);
2025+ setAddProjectError(null);
2026+ },
2027+ [setAddProjectError, setNewCwd],
2028+ );
2029+ const handleAddProjectInputKeyDown = useCallback(
2030+ (event: React.KeyboardEvent<HTMLInputElement>) => {
2031+ if (event.key === "Enter") handleAddProject();
2032+ if (event.key === "Escape") {
2033+ setAddingProject(false);
2034+ setAddProjectError(null);
13002035 }
1301− if (clicked === "copy-path") {
1302− copyPathToClipboard(project.cwd, { path: project.cwd });
1303− return;
2036+ },
2037+ [handleAddProject, setAddProjectError, setAddingProject],
2038+ );
2039+ const handleBrowseForFolderClick = useCallback(() => {
2040+ void handlePickFolder();
2041+ }, [handlePickFolder]);
2042+
2043+ return (
2044+ <SidebarContent className="gap-0">
2045+ {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
2046+ <SidebarGroup className="px-2 pt-2 pb-0">
2047+ <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
2048+ <TriangleAlertIcon />
2049+ <AlertTitle>Intel build on Apple Silicon</AlertTitle>
2050+ <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
2051+ {desktopUpdateButtonAction !== "none" ? (
2052+ <AlertAction>
2053+ <Button
2054+ size="xs"
2055+ variant="outline"
2056+ disabled={desktopUpdateButtonDisabled}
2057+ onClick={handleDesktopUpdateButtonClick}
2058+ >
2059+ {desktopUpdateButtonAction === "download"
2060+ ? "Download ARM build"
2061+ : "Install ARM build"}
2062+ </Button>
2063+ </AlertAction>
2064+ ) : null}
2065+ </Alert>
2066+ </SidebarGroup>
2067+ ) : null}
2068+ <SidebarGroup className="px-2 py-2">
2069+ <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">
2070+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
2071+ Projects
2072+ </span>
2073+ <div className="flex items-center gap-1">
2074+ <ProjectSortMenu
2075+ projectSortOrder={projectSortOrder}
2076+ threadSortOrder={threadSortOrder}
2077+ onProjectSortOrderChange={handleProjectSortOrderChange}
2078+ onThreadSortOrderChange={handleThreadSortOrderChange}
2079+ />
2080+ <Tooltip>
2081+ <TooltipTrigger
2082+ render={
2083+ <button
2084+ type="button"
2085+ aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
2086+ aria-pressed={shouldShowProjectPathEntry}
2087+ className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
2088+ onClick={handleStartAddProject}
2089+ />
2090+ }
2091+ >
2092+ <PlusIcon
2093+ className={`size-3.5 transition-transform duration-150 ${
2094+ shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
2095+ }`}
2096+ />
2097+ </TooltipTrigger>
2098+ <TooltipPopup side="right">
2099+ {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
2100+ </TooltipPopup>
2101+ </Tooltip>
2102+ </div>
2103+ </div>
2104+ {shouldShowProjectPathEntry && (
2105+ <div className="mb-2 px-1">
2106+ {isElectron && (
2107+ <button
2108+ type="button"
2109+ className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
2110+ onClick={handleBrowseForFolderClick}
2111+ disabled={isPickingFolder || isAddingProject}
2112+ >
2113+ <FolderIcon className="size-3.5" />
2114+ {isPickingFolder ? "Picking folder..." : "Browse for folder"}
2115+ </button>
2116+ )}
2117+ <div className="flex gap-1.5">
2118+ <input
2119+ ref={addProjectInputRef}
2120+ className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${
2121+ addProjectError
2122+ ? "border-red-500/70 focus:border-red-500"
2123+ : "border-border focus:border-ring"
2124+ }`}
2125+ placeholder="/path/to/project"
2126+ value={newCwd}
2127+ onChange={handleAddProjectInputChange}
2128+ onKeyDown={handleAddProjectInputKeyDown}
2129+ autoFocus
2130+ />
2131+ <button
2132+ type="button"
2133+ className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60"
2134+ onClick={handleAddProject}
2135+ disabled={!canAddProject}
2136+ >
2137+ {isAddingProject ? "Adding..." : "Add"}
2138+ </button>
2139+ </div>
2140+ {addProjectError && (
2141+ <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400">
2142+ {addProjectError}
2143+ </p>
2144+ )}
2145+ </div>
2146+ )}
2147+
2148+ {isManualProjectSorting ? (
2149+ <DndContext
2150+ sensors={projectDnDSensors}
2151+ collisionDetection={projectCollisionDetection}
2152+ modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
2153+ onDragStart={handleProjectDragStart}
2154+ onDragEnd={handleProjectDragEnd}
2155+ onDragCancel={handleProjectDragCancel}
2156+ >
2157+ <SidebarMenu>
2158+ <SortableContext
2159+ items={sortedProjects.map((project) => project.projectKey)}
2160+ strategy={verticalListSortingStrategy}
2161+ >
2162+ {sortedProjects.map((project) => (
2163+ <SortableProjectItem key={project.projectKey} projectId={project.projectKey}>
2164+ {(dragHandleProps) => (
2165+ <SidebarProjectItem
2166+ project={project}
2167+ isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)}
2168+ activeRouteThreadKey={
2169+ activeRouteProjectKey === project.projectKey ? routeThreadKey : null
2170+ }
2171+ newThreadShortcutLabel={newThreadShortcutLabel}
2172+ handleNewThread={handleNewThread}
2173+ archiveThread={archiveThread}
2174+ deleteThread={deleteThread}
2175+ threadJumpLabelByKey={threadJumpLabelByKey}
2176+ attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2177+ expandThreadListForProject={expandThreadListForProject}
2178+ collapseThreadListForProject={collapseThreadListForProject}
2179+ dragInProgressRef={dragInProgressRef}
2180+ suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
2181+ suppressProjectClickForContextMenuRef={
2182+ suppressProjectClickForContextMenuRef
2183+ }
2184+ isManualProjectSorting={isManualProjectSorting}
2185+ dragHandleProps={dragHandleProps}
2186+ />
2187+ )}
2188+ </SortableProjectItem>
2189+ ))}
2190+ </SortableContext>
2191+ </SidebarMenu>
2192+ </DndContext>
2193+ ) : (
2194+ <SidebarMenu ref={attachProjectListAutoAnimateRef}>
2195+ {sortedProjects.map((project) => (
2196+ <SidebarProjectListRow
2197+ key={project.projectKey}
2198+ project={project}
2199+ isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)}
2200+ activeRouteThreadKey={
2201+ activeRouteProjectKey === project.projectKey ? routeThreadKey : null
2202+ }
2203+ newThreadShortcutLabel={newThreadShortcutLabel}
2204+ handleNewThread={handleNewThread}
2205+ archiveThread={archiveThread}
2206+ deleteThread={deleteThread}
2207+ threadJumpLabelByKey={threadJumpLabelByKey}
2208+ attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2209+ expandThreadListForProject={expandThreadListForProject}
2210+ collapseThreadListForProject={collapseThreadListForProject}
2211+ dragInProgressRef={dragInProgressRef}
2212+ suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
2213+ suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
2214+ isManualProjectSorting={isManualProjectSorting}
2215+ dragHandleProps={null}
2216+ />
2217+ ))}
2218+ </SidebarMenu>
2219+ )}
2220+
2221+ {projectsLength === 0 && !shouldShowProjectPathEntry && (
2222+ <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
2223+ No projects yet
2224+ </div>
2225+ )}
2226+ </SidebarGroup>
2227+ </SidebarContent>
2228+ );
2229+});
2230+
2231+export default function Sidebar() {
2232+ const projects = useStore(useShallow(selectProjectsAcrossEnvironments));
2233+ const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments));
2234+ const activeEnvironmentId = useStore((store) => store.activeEnvironmentId);
2235+ const projectExpandedById = useUiStateStore((store) => store.projectExpandedById);
2236+ const projectOrder = useUiStateStore((store) => store.projectOrder);
2237+ const reorderProjects = useUiStateStore((store) => store.reorderProjects);
2238+ const navigate = useNavigate();
2239+ const pathname = useLocation({ select: (loc) => loc.pathname });
2240+ const isOnSettings = pathname.startsWith("/settings");
2241+ const appSettings = useSettings();
2242+ const { updateSettings } = useUpdateSettings();
2243+ const { handleNewThread } = useHandleNewThread();
2244+ const { archiveThread, deleteThread } = useThreadActions();
2245+ const routeThreadRef = useParams({
2246+ strict: false,
2247+ select: (params) => resolveThreadRouteRef(params),
2248+ });
2249+ const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
2250+ const keybindings = useServerKeybindings();
2251+ const [addingProject, setAddingProject] = useState(false);
2252+ const [newCwd, setNewCwd] = useState("");
2253+ const [isPickingFolder, setIsPickingFolder] = useState(false);
2254+ const [isAddingProject, setIsAddingProject] = useState(false);
2255+ const [addProjectError, setAddProjectError] = useState<string | null>(null);
2256+ const addProjectInputRef = useRef<HTMLInputElement | null>(null);
2257+ const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
2258+ ReadonlySet<string>
2259+ >(() => new Set());
2260+ const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
2261+ const dragInProgressRef = useRef(false);
2262+ const suppressProjectClickAfterDragRef = useRef(false);
2263+ const suppressProjectClickForContextMenuRef = useRef(false);
2264+ const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
2265+ const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size);
2266+ const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
2267+ const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
2268+ const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform);
2269+ const platform = navigator.platform;
2270+ const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
2271+ const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
2272+ const orderedProjects = useMemo(() => {
2273+ return orderItemsByPreferredIds({
2274+ items: projects,
2275+ preferredIds: projectOrder,
2276+ getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
2277+ });
2278+ }, [projectOrder, projects]);
2279+ const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(
2280+ () =>
2281+ orderedProjects.map((project) => ({
2282+ ...project,
2283+ projectKey: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
2284+ })),
2285+ [orderedProjects],
2286+ );
2287+ const sidebarProjectByKey = useMemo(
2288+ () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)),
2289+ [sidebarProjects],
2290+ );
2291+ const sidebarThreadByKey = useMemo(
2292+ () =>
2293+ new Map(
2294+ sidebarThreads.map(
2295+ (thread) =>
2296+ [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
2297+ ),
2298+ ),
2299+ [sidebarThreads],
2300+ );
2301+ const activeRouteProjectKey = useMemo(() => {
2302+ if (!routeThreadKey) {
2303+ return null;
2304+ }
2305+ const activeThread = sidebarThreadByKey.get(routeThreadKey);
2306+ return activeThread
2307+ ? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId))
2308+ : null;
2309+ }, [routeThreadKey, sidebarThreadByKey]);
2310+ const threadsByProjectKey = useMemo(() => {
2311+ const next = new Map<string, SidebarThreadSummary[]>();
2312+ for (const thread of sidebarThreads) {
2313+ const projectKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
2314+ const existing = next.get(projectKey);
2315+ if (existing) {
2316+ existing.push(thread);
2317+ } else {
2318+ next.set(projectKey, [thread]);
13042319 }
1305− if (clicked !== "delete") return;
2320+ }
2321+ return next;
2322+ }, [sidebarThreads]);
2323+ const getCurrentSidebarShortcutContext = useCallback(
2324+ () => ({
2325+ terminalFocus: isTerminalFocused(),
2326+ terminalOpen: routeThreadRef
2327+ ? selectThreadTerminalState(
2328+ useTerminalStateStore.getState().terminalStateByThreadKey,
2329+ routeThreadRef,
2330+ ).terminalOpen
2331+ : false,
2332+ }),
2333+ [routeThreadRef],
2334+ );
2335+ const newThreadShortcutLabelOptions = useMemo(
2336+ () => ({
2337+ platform,
2338+ context: {
2339+ terminalFocus: false,
2340+ terminalOpen: false,
2341+ },
2342+ }),
2343+ [platform],
2344+ );
2345+ const newThreadShortcutLabel =
2346+ shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ??
2347+ shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions);
2348+ const focusMostRecentThreadForProject = useCallback(
2349+ (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => {
2350+ const projectKey = scopedProjectKey(
2351+ scopeProjectRef(projectRef.environmentId, projectRef.projectId),
2352+ );
2353+ const latestThread = sortThreadsForSidebar(
2354+ (threadsByProjectKey.get(projectKey) ?? []).filter((thread) => thread.archivedAt === null),
2355+ appSettings.sidebarThreadSortOrder,
2356+ )[0];
2357+ if (!latestThread) return;
13062358
1307− const projectThreadIds = threadIdsByProjectId[projectId] ?? [];
1308− if (projectThreadIds.length > 0) {
1309− toastManager.add({
1310− type: "warning",
1311− title: "Project is not empty",
1312− description: "Delete all threads in this project before removing it.",
2359+ void navigate({
2360+ to: "/$environmentId/$threadId",
2361+ params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)),
2362+ });
2363+ },
2364+ [appSettings.sidebarThreadSortOrder, navigate, threadsByProjectKey],
2365+ );
2366+
2367+ const addProjectFromPath = useCallback(
2368+ async (rawCwd: string) => {
2369+ const cwd = rawCwd.trim();
2370+ if (!cwd || isAddingProject) return;
2371+ const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined;
2372+ if (!api) return;
2373+
2374+ setIsAddingProject(true);
2375+ const finishAddingProject = () => {
2376+ setIsAddingProject(false);
2377+ setNewCwd("");
2378+ setAddProjectError(null);
2379+ setAddingProject(false);
2380+ };
2381+
2382+ const existing = projects.find((project) => project.cwd === cwd);
2383+ if (existing) {
2384+ focusMostRecentThreadForProject({
2385+ environmentId: existing.environmentId,
2386+ projectId: existing.id,
13132387 });
2388+ finishAddingProject();
13142389 return;
13152390 }
13162391
1317− const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`);
1318− if (!confirmed) return;
1319−
2392+ const projectId = newProjectId();
2393+ const createdAt = new Date().toISOString();
2394+ const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
13202395 try {
1321− const projectDraftThread = getDraftThreadByProjectId(projectId);
1322− if (projectDraftThread) {
1323− clearComposerDraftForThread(projectDraftThread.threadId);
1324− }
1325− clearProjectDraftThreadId(projectId);
13262396 await api.orchestration.dispatchCommand({
1327− type: "project.delete",
2397+ type: "project.create",
13282398 commandId: newCommandId(),
13292399 projectId,
2400+ title,
2401+ workspaceRoot: cwd,
2402+ defaultModelSelection: {
2403+ provider: "codex",
2404+ model: DEFAULT_MODEL_BY_PROVIDER.codex,
2405+ },
2406+ createdAt,
13302407 });
2408+ if (activeEnvironmentId !== null) {
2409+ await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), {
2410+ envMode: appSettings.defaultThreadEnvMode,
2411+ }).catch(() => undefined);
2412+ }
13312413 } catch (error) {
1332− const message = error instanceof Error ? error.message : "Unknown error removing project.";
1333− console.error("Failed to remove project", { projectId, error });
1334− toastManager.add({
1335− type: "error",
1336− title: `Failed to remove "${project.name}"`,
1337− description: message,
1338− });
2414+ const description =
2415+ error instanceof Error ? error.message : "An error occurred while adding the project.";
2416+ setIsAddingProject(false);
2417+ if (shouldBrowseForProjectImmediately) {
2418+ toastManager.add({
2419+ type: "error",
2420+ title: "Failed to add project",
2421+ description,
2422+ });
2423+ } else {
2424+ setAddProjectError(description);
2425+ }
2426+ return;
13392427 }
2428+ finishAddingProject();
13402429 },
13412430 [
1342− clearComposerDraftForThread,
1343− clearProjectDraftThreadId,
1344− copyPathToClipboard,
1345− getDraftThreadByProjectId,
2431+ focusMostRecentThreadForProject,
2432+ activeEnvironmentId,
2433+ handleNewThread,
2434+ isAddingProject,
13462435 projects,
1347− threadIdsByProjectId,
2436+ shouldBrowseForProjectImmediately,
2437+ appSettings.defaultThreadEnvMode,
13482438 ],
13492439 );
13502440
2441+ const handleAddProject = () => {
2442+ void addProjectFromPath(newCwd);
2443+ };
2444+
2445+ const canAddProject = newCwd.trim().length > 0 && !isAddingProject;
2446+
2447+ const handlePickFolder = async () => {
2448+ const api = readLocalApi();
2449+ if (!api || isPickingFolder) return;
2450+ setIsPickingFolder(true);
2451+ let pickedPath: string | null = null;
2452+ try {
2453+ pickedPath = await api.dialogs.pickFolder();
2454+ } catch {
2455+ // Ignore picker failures and leave the current thread selection unchanged.
2456+ }
2457+ if (pickedPath) {
2458+ await addProjectFromPath(pickedPath);
2459+ } else if (!shouldBrowseForProjectImmediately) {
2460+ addProjectInputRef.current?.focus();
2461+ }
2462+ setIsPickingFolder(false);
2463+ };
2464+
2465+ const handleStartAddProject = () => {
2466+ setAddProjectError(null);
2467+ if (shouldBrowseForProjectImmediately) {
2468+ void handlePickFolder();
2469+ return;
2470+ }
2471+ setAddingProject((prev) => !prev);
2472+ };
2473+
2474+ const navigateToThread = useCallback(
2475+ (threadRef: ScopedThreadRef) => {
2476+ if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
2477+ clearSelection();
2478+ }
2479+ setSelectionAnchor(scopedThreadKey(threadRef));
2480+ void navigate({
2481+ to: "/$environmentId/$threadId",
2482+ params: buildThreadRouteParams(threadRef),
2483+ });
2484+ },
2485+ [clearSelection, navigate, setSelectionAnchor],
2486+ );
2487+
13512488 const projectDnDSensors = useSensors(
13522489 useSensor(PointerSensor, {
13532490 activationConstraint: { distance: 6 },
@@ -1371,10 +2508,10 @@ export default function Sidebar() { · 17 unmodified lines
13712508 dragInProgressRef.current = false;
13722509 const { active, over } = event;
13732510 if (!over || active.id === over.id) return;
1374− const activeProject = sidebarProjects.find((project) => project.id === active.id);
1375− const overProject = sidebarProjects.find((project) => project.id === over.id);
2511+ const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);
2512+ const overProject = sidebarProjects.find((project) => project.projectKey === over.id);
13762513 if (!activeProject || !overProject) return;
1377− reorderProjects(activeProject.id, overProject.id);
2514+ reorderProjects(activeProject.projectKey, overProject.projectKey);
13782515 },
13792516 [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects],
13802517 );
@@ -1412,148 +2549,117 @@ export default function Sidebar() { · 31 unmodified lines
14122549 animatedThreadListsRef.current.add(node);
14132550 }, []);
14142551
1415− const handleProjectTitlePointerDownCapture = useCallback(
1416− (event: PointerEvent<HTMLButtonElement>) => {
1417− suppressProjectClickForContextMenuRef.current = false;
1418− if (
1419− isContextMenuPointerDown({
1420− button: event.button,
1421− ctrlKey: event.ctrlKey,
1422− isMac: isMacPlatform(navigator.platform),
1423− })
1424− ) {
1425− // Keep context-menu gestures from arming the sortable drag sensor.
1426− event.stopPropagation();
1427− }
1428−
1429− suppressProjectClickAfterDragRef.current = false;
1430− },
1431− [],
1432− );
1433−
14342552 const visibleThreads = useMemo(
14352553 () => sidebarThreads.filter((thread) => thread.archivedAt === null),
14362554 [sidebarThreads],
14372555 );
1438− const sortedProjects = useMemo(
1439− () =>
1440− sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder),
1441− [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads],
1442− );
2556+ const sortedProjects = useMemo(() => {
2557+ const sortableProjects = sidebarProjects.map((project) => ({
2558+ ...project,
2559+ id: project.projectKey,
2560+ }));
2561+ const sortableThreads = visibleThreads.map((thread) => ({
2562+ ...thread,
2563+ projectId: scopedProjectKey(
2564+ scopeProjectRef(thread.environmentId, thread.projectId),
2565+ ) as ProjectId,
2566+ }));
2567+ return sortProjectsForSidebar(
2568+ sortableProjects,
2569+ sortableThreads,
2570+ appSettings.sidebarProjectSortOrder,
2571+ ).flatMap((project) => {
2572+ const resolvedProject = sidebarProjectByKey.get(project.id);
2573+ return resolvedProject ? [resolvedProject] : [];
2574+ });
2575+ }, [appSettings.sidebarProjectSortOrder, sidebarProjectByKey, sidebarProjects, visibleThreads]);
14432576 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";
1444− const renderedProjects = useMemo(
2577+ const visibleSidebarThreadKeys = useMemo(
14452578 () =>
1446− sortedProjects.map((project) => {
1447− const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) =>
1448− resolveThreadStatusPill({
1449− thread: {
1450− ...thread,
1451− lastVisitedAt: threadLastVisitedAtById[thread.id],
1452− },
1453− });
2579+ sortedProjects.flatMap((project) => {
14542580 const projectThreads = sortThreadsForSidebar(
1455− (threadIdsByProjectId[project.id] ?? [])
1456− .map((threadId) => sidebarThreadsById[threadId])
1457− .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined)
1458− .filter((thread) => thread.archivedAt === null),
2581+ (threadsByProjectKey.get(project.projectKey) ?? []).filter(
2582+ (thread) => thread.archivedAt === null,
2583+ ),
14592584 appSettings.sidebarThreadSortOrder,
14602585 );
1461− const projectStatus = resolveProjectStatusIndicator(
1462− projectThreads.map((thread) => resolveProjectThreadStatus(thread)),
1463− );
1464− const activeThreadId = routeThreadId ?? undefined;
1465− const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
2586+ const projectExpanded = projectExpandedById[project.projectKey] ?? true;
2587+ const activeThreadKey = routeThreadKey ?? undefined;
14662588 const pinnedCollapsedThread =
1467− !project.expanded && activeThreadId
1468− ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null)
2589+ !projectExpanded && activeThreadKey
2590+ ? (projectThreads.find(
2591+ (thread) =>
2592+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) ===
2593+ activeThreadKey,
2594+ ) ?? null)
14692595 : null;
1470− const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null;
1471− const {
1472− hasHiddenThreads,
1473− hiddenThreads,
1474− visibleThreads: visibleProjectThreads,
1475− } = getVisibleThreadsForProject({
1476− threads: projectThreads,
1477− activeThreadId,
1478− isThreadListExpanded,
1479− previewLimit: THREAD_PREVIEW_LIMIT,
1480− });
1481− const hiddenThreadStatus = resolveProjectStatusIndicator(
1482− hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)),
2596+ const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null;
2597+ if (!shouldShowThreadPanel) {
2598+ return [];
2599+ }
2600+ const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey);
2601+ const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;
2602+ const previewThreads =
2603+ isThreadListExpanded || !hasOverflowingThreads
2604+ ? projectThreads
2605+ : projectThreads.slice(0, THREAD_PREVIEW_LIMIT);
2606+ const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads;
2607+ return renderedThreads.map((thread) =>
2608+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
14832609 );
1484− const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
1485− const renderedThreadIds = pinnedCollapsedThread
1486− ? [pinnedCollapsedThread.id]
1487− : visibleProjectThreads.map((thread) => thread.id);
1488− const showEmptyThreadState = project.expanded && projectThreads.length === 0;
1489−
1490− return {
1491− hasHiddenThreads,
1492− hiddenThreadStatus,
1493− orderedProjectThreadIds,
1494− project,
1495− projectStatus,
1496− renderedThreadIds,
1497− showEmptyThreadState,
1498− shouldShowThreadPanel,
1499− isThreadListExpanded,
1500− };
15012610 }),
15022611 [
15032612 appSettings.sidebarThreadSortOrder,
15042613 expandedThreadListsByProject,
1505− routeThreadId,
2614+ projectExpandedById,
2615+ routeThreadKey,
15062616 sortedProjects,
1507− sidebarThreadsById,
1508− threadIdsByProjectId,
1509− threadLastVisitedAtById,
2617+ threadsByProjectKey,
15102618 ],
15112619 );
1512− const visibleSidebarThreadIds = useMemo(
1513− () => getVisibleSidebarThreadIds(renderedProjects),
1514− [renderedProjects],
1515− );
1516− const threadJumpCommandById = useMemo(() => {
1517− const mapping = new Map<ThreadId, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>();
1518− for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) {
2620+ const threadJumpCommandByKey = useMemo(() => {
2621+ const mapping = new Map<string, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>();
2622+ for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) {
15192623 const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex);
15202624 if (!jumpCommand) {
15212625 return mapping;
15222626 }
1523− mapping.set(threadId, jumpCommand);
2627+ mapping.set(threadKey, jumpCommand);
15242628 }
15252629
15262630 return mapping;
1527− }, [visibleSidebarThreadIds]);
1528− const threadJumpThreadIds = useMemo(
1529− () => [...threadJumpCommandById.keys()],
1530− [threadJumpCommandById],
1531− );
1532− const threadJumpLabelById = useMemo(() => {
1533− const mapping = new Map<ThreadId, string>();
1534− for (const [threadId, command] of threadJumpCommandById) {
1535− const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions);
1536− if (label) {
1537− mapping.set(threadId, label);
1538− }
1539− }
1540− return mapping;
1541− }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]);
1542− const orderedSidebarThreadIds = visibleSidebarThreadIds;
2631+ }, [visibleSidebarThreadKeys]);
2632+ const threadJumpThreadKeys = useMemo(
2633+ () => [...threadJumpCommandByKey.keys()],
2634+ [threadJumpCommandByKey],
2635+ );
2636+ const [threadJumpLabelByKey, setThreadJumpLabelByKey] =
2637+ useState<ReadonlyMap<string, string>>(EMPTY_THREAD_JUMP_LABELS);
2638+ const visibleThreadJumpLabelByKey = showThreadJumpHints
2639+ ? threadJumpLabelByKey
2640+ : EMPTY_THREAD_JUMP_LABELS;
2641+ const orderedSidebarThreadKeys = visibleSidebarThreadKeys;
15432642
15442643 useEffect(() => {
1545− const getShortcutContext = () => ({
1546− terminalFocus: isTerminalFocused(),
1547− terminalOpen: routeTerminalOpen,
1548− });
1549−
15502644 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
1551− updateThreadJumpHintsVisibility(
1552− shouldShowThreadJumpHints(event, keybindings, {
2645+ const shortcutContext = getCurrentSidebarShortcutContext();
2646+ const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, {
2647+ platform,
2648+ context: shortcutContext,
2649+ });
2650+ setThreadJumpLabelByKey((current) => {
2651+ if (!shouldShowHints) {
2652+ return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS;
2653+ }
2654+ const nextLabelMap = buildThreadJumpLabelMap({
2655+ keybindings,
15532656 platform,
1554− context: getShortcutContext(),
1555− }),
1556− );
2657+ terminalOpen: shortcutContext.terminalOpen,
2658+ threadJumpCommandByKey,
2659+ });
2660+ return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap;
2661+ });
2662+ updateThreadJumpHintsVisibility(shouldShowHints);
15572663
15582664 if (event.defaultPrevented || event.repeat) {
15592665 return;
@@ -1561,22 +2667,26 @@ export default function Sidebar() { · 1 unmodified line
15612667
15622668 const command = resolveShortcutCommand(event, keybindings, {
15632669 platform,
1564− context: getShortcutContext(),
2670+ context: shortcutContext,
15652671 });
15662672 const traversalDirection = threadTraversalDirectionFromCommand(command);
15672673 if (traversalDirection !== null) {
1568− const targetThreadId = resolveAdjacentThreadId({
1569− threadIds: orderedSidebarThreadIds,
1570− currentThreadId: routeThreadId,
2674+ const targetThreadKey = resolveAdjacentThreadId({
2675+ threadIds: orderedSidebarThreadKeys,
2676+ currentThreadId: routeThreadKey,
15712677 direction: traversalDirection,
15722678 });
1573− if (!targetThreadId) {
2679+ if (!targetThreadKey) {
2680+ return;
2681+ }
2682+ const targetThread = sidebarThreadByKey.get(targetThreadKey);
2683+ if (!targetThread) {
15742684 return;
15752685 }
15762686
15772687 event.preventDefault();
15782688 event.stopPropagation();
1579− navigateToThread(targetThreadId);
2689+ navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
15802690 return;
15812691 }
15822692
@@ -1585,26 +2695,45 @@ export default function Sidebar() { · 2 unmodified lines
15852695 return;
15862696 }
15872697
1588− const targetThreadId = threadJumpThreadIds[jumpIndex];
1589− if (!targetThreadId) {
2698+ const targetThreadKey = threadJumpThreadKeys[jumpIndex];
2699+ if (!targetThreadKey) {
2700+ return;
2701+ }
2702+ const targetThread = sidebarThreadByKey.get(targetThreadKey);
2703+ if (!targetThread) {
15902704 return;
15912705 }
15922706
15932707 event.preventDefault();
15942708 event.stopPropagation();
1595− navigateToThread(targetThreadId);
2709+ navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
15962710 };
15972711
15982712 const onWindowKeyUp = (event: globalThis.KeyboardEvent) => {
1599− updateThreadJumpHintsVisibility(
1600− shouldShowThreadJumpHints(event, keybindings, {
2713+ const shortcutContext = getCurrentSidebarShortcutContext();
2714+ const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, {
2715+ platform,
2716+ context: shortcutContext,
2717+ });
2718+ setThreadJumpLabelByKey((current) => {
2719+ if (!shouldShowHints) {
2720+ return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS;
2721+ }
2722+ const nextLabelMap = buildThreadJumpLabelMap({
2723+ keybindings,
16012724 platform,
1602− context: getShortcutContext(),
1603− }),
1604− );
2725+ terminalOpen: shortcutContext.terminalOpen,
2726+ threadJumpCommandByKey,
2727+ });
2728+ return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap;
2729+ });
2730+ updateThreadJumpHintsVisibility(shouldShowHints);
16052731 };
16062732
16072733 const onWindowBlur = () => {
2734+ setThreadJumpLabelByKey((current) =>
2735+ current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS,
2736+ );
16082737 updateThreadJumpHintsVisibility(false);
16092738 };
16102739
@@ -1618,301 +2747,21 @@ export default function Sidebar() { · 7 unmodified lines
16182747 window.removeEventListener("blur", onWindowBlur);
16192748 };
16202749 }, [
2750+ getCurrentSidebarShortcutContext,
16212751 keybindings,
16222752 navigateToThread,
1623− orderedSidebarThreadIds,
2753+ orderedSidebarThreadKeys,
16242754 platform,
1625− routeTerminalOpen,
1626− routeThreadId,
1627− threadJumpThreadIds,
2755+ routeThreadKey,
2756+ sidebarThreadByKey,
2757+ threadJumpCommandByKey,
2758+ threadJumpThreadKeys,
16282759 updateThreadJumpHintsVisibility,
16292760 ]);
16302761
1631− function renderProjectItem(
1632− renderedProject: (typeof renderedProjects)[number],
1633− dragHandleProps: SortableProjectHandleProps | null,
1634− ) {
1635− const {
1636− hasHiddenThreads,
1637− hiddenThreadStatus,
1638− orderedProjectThreadIds,
1639− project,
1640− projectStatus,
1641− renderedThreadIds,
1642− showEmptyThreadState,
1643− shouldShowThreadPanel,
1644− isThreadListExpanded,
1645− } = renderedProject;
1646− return (
1647− <>
1648− <div className="group/project-header relative">
1649− <SidebarMenuButton
1650− ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
1651− size="sm"
1652− className={`gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${
1653− isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
1654− }`}
1655− {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
1656− {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
1657− onPointerDownCapture={handleProjectTitlePointerDownCapture}
1658− onClick={(event) => handleProjectTitleClick(event, project.id)}
1659− onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}
1660− onContextMenu={(event) => {
1661− event.preventDefault();
1662− suppressProjectClickForContextMenuRef.current = true;
1663− void handleProjectContextMenu(project.id, {
1664− x: event.clientX,
1665− y: event.clientY,
1666− });
1667− }}
1668− >
1669− {!project.expanded && projectStatus ? (
1670− <span
1671− aria-hidden="true"
1672− title={projectStatus.label}
1673− className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
1674− >
1675− <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
1676− <span
1677− className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
1678− projectStatus.pulse ? "animate-pulse" : ""
1679− }`}
1680− />
1681− </span>
1682− <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
1683− </span>
1684− ) : (
1685− <ChevronRightIcon
1686− className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1687− project.expanded ? "rotate-90" : ""
1688− }`}
1689− />
1690− )}
1691− <ProjectFavicon cwd={project.cwd} />
1692− {renamingProjectId === project.id ? (
1693− <input
1694− ref={(element) => {
1695− if (element && projectRenamingInputRef.current !== element) {
1696− projectRenamingInputRef.current = element;
1697− element.focus();
1698− element.select();
1699− }
1700− }}
1701− className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-xs font-medium text-foreground/90 outline-none"
1702− value={renamingProjectTitle}
1703− onChange={(event) => setRenamingProjectTitle(event.target.value)}
1704− onKeyDown={(event) => {
1705− event.stopPropagation();
1706− if (event.key === "Enter") {
1707− event.preventDefault();
1708− projectRenamingCommittedRef.current = true;
1709− void commitProjectRename(project.id, renamingProjectTitle, project.name);
1710− } else if (event.key === "Escape") {
1711− event.preventDefault();
1712− projectRenamingCommittedRef.current = true;
1713− cancelProjectRename();
1714− }
1715− }}
1716− onBlur={() => {
1717− if (!projectRenamingCommittedRef.current) {
1718− void commitProjectRename(project.id, renamingProjectTitle, project.name);
1719− }
1720− }}
1721− onClick={(event) => event.stopPropagation()}
1722− onPointerDown={(event) => event.stopPropagation()}
1723− />
1724− ) : (
1725− <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1726− {project.name}
1727− </span>
1728− )}
1729− </SidebarMenuButton>
1730− <Tooltip>
1731− <TooltipTrigger
1732− render={
1733− <SidebarMenuAction
1734− render={
1735− <button
1736− type="button"
1737− aria-label={`Create new thread in ${project.name}`}
1738− data-testid="new-thread-button"
1739− />
1740− }
1741− showOnHover
1742− className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1743− onClick={(event) => {
1744− event.preventDefault();
1745− event.stopPropagation();
1746− const seedContext = resolveSidebarNewThreadSeedContext({
1747− projectId: project.id,
1748− defaultEnvMode: resolveSidebarNewThreadEnvMode({
1749− defaultEnvMode: appSettings.defaultThreadEnvMode,
1750− }),
1751− activeThread:
1752− activeThread && activeThread.projectId === project.id
1753− ? {
1754− projectId: activeThread.projectId,
1755− branch: activeThread.branch,
1756− worktreePath: activeThread.worktreePath,
1757− }
1758− : null,
1759− activeDraftThread:
1760− activeDraftThread && activeDraftThread.projectId === project.id
1761− ? {
1762− projectId: activeDraftThread.projectId,
1763− branch: activeDraftThread.branch,
1764− worktreePath: activeDraftThread.worktreePath,
1765− envMode: activeDraftThread.envMode,
1766− }
1767− : null,
1768− });
1769− void handleNewThread(project.id, {
1770− ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}),
1771− ...(seedContext.worktreePath !== undefined
1772− ? { worktreePath: seedContext.worktreePath }
1773− : {}),
1774− envMode: seedContext.envMode,
1775− });
1776− }}
1777− >
1778− <SquarePenIcon className="size-3.5" />
1779− </SidebarMenuAction>
1780− }
1781− />
1782− <TooltipPopup side="top">
1783− {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
1784− </TooltipPopup>
1785− </Tooltip>
1786− </div>
1787−
1788− <SidebarMenuSub
1789− ref={attachThreadListAutoAnimateRef}
1790− className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0"
1791− >
1792− {shouldShowThreadPanel && showEmptyThreadState ? (
1793− <SidebarMenuSubItem className="w-full" data-thread-selection-safe>
1794− <div
1795− data-thread-selection-safe
1796− className="flex h-6 w-full translate-x-0 items-center px-2 text-left text-[10px] text-muted-foreground/60"
1797− >
1798− <span>No threads yet</span>
1799− </div>
1800− </SidebarMenuSubItem>
1801− ) : null}
1802− {shouldShowThreadPanel &&
1803− renderedThreadIds.map((threadId) => (
1804− <SidebarThreadRow
1805− key={threadId}
1806− threadId={threadId}
1807− projectCwd={project.cwd}
1808− orderedProjectThreadIds={orderedProjectThreadIds}
1809− routeThreadId={routeThreadId}
1810− selectedThreadIds={selectedThreadIds}
1811− showThreadJumpHints={showThreadJumpHints}
1812− jumpLabel={threadJumpLabelById.get(threadId) ?? null}
1813− appSettingsConfirmThreadArchive={appSettings.confirmThreadArchive}
1814− renamingThreadId={renamingThreadId}
1815− renamingTitle={renamingTitle}
1816− setRenamingTitle={setRenamingTitle}
1817− onRenamingInputMount={handleRenamingInputMount}
1818− hasRenameCommitted={hasRenameCommitted}
1819− markRenameCommitted={markRenameCommitted}
1820− confirmingArchiveThreadId={confirmingArchiveThreadId}
1821− setConfirmingArchiveThreadId={setConfirmingArchiveThreadId}
1822− confirmArchiveButtonRefs={confirmArchiveButtonRefs}
1823− handleThreadClick={handleThreadClick}
1824− navigateToThread={navigateToThread}
1825− handleMultiSelectContextMenu={handleMultiSelectContextMenu}
1826− handleThreadContextMenu={handleThreadContextMenu}
1827− clearSelection={clearSelection}
1828− commitRename={commitRename}
1829− cancelRename={cancelRename}
1830− attemptArchiveThread={attemptArchiveThread}
1831− openPrLink={openPrLink}
1832− />
1833− ))}
1834−
1835− {project.expanded && hasHiddenThreads && !isThreadListExpanded && (
1836− <SidebarMenuSubItem className="w-full">
1837− <SidebarMenuSubButton
1838− render={<button type="button" />}
1839− data-thread-selection-safe
1840− size="sm"
1841− className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1842− onClick={() => {
1843− expandThreadListForProject(project.id);
1844− }}
1845− >
1846− <span className="flex min-w-0 flex-1 items-center gap-2">
1847− {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />}
1848− <span>Show more</span>
1849− </span>
1850− </SidebarMenuSubButton>
1851− </SidebarMenuSubItem>
1852− )}
1853− {project.expanded && hasHiddenThreads && isThreadListExpanded && (
1854− <SidebarMenuSubItem className="w-full">
1855− <SidebarMenuSubButton
1856− render={<button type="button" />}
1857− data-thread-selection-safe
1858− size="sm"
1859− className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1860− onClick={() => {
1861− collapseThreadListForProject(project.id);
1862− }}
1863− >
1864− <span>Show less</span>
1865− </SidebarMenuSubButton>
1866− </SidebarMenuSubItem>
1867− )}
1868− </SidebarMenuSub>
1869− </>
1870− );
1871− }
1872−
1873− const handleProjectTitleClick = useCallback(
1874− (event: MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
1875− if (suppressProjectClickForContextMenuRef.current) {
1876− suppressProjectClickForContextMenuRef.current = false;
1877− event.preventDefault();
1878− event.stopPropagation();
1879− return;
1880− }
1881− if (dragInProgressRef.current) {
1882− event.preventDefault();
1883− event.stopPropagation();
1884− return;
1885− }
1886− if (suppressProjectClickAfterDragRef.current) {
1887− // Consume the synthetic click emitted after a drag release.
1888− suppressProjectClickAfterDragRef.current = false;
1889− event.preventDefault();
1890− event.stopPropagation();
1891− return;
1892− }
1893− if (selectedThreadIds.size > 0) {
1894− clearSelection();
1895− }
1896− toggleProject(projectId);
1897− },
1898− [clearSelection, selectedThreadIds.size, toggleProject],
1899− );
1900−
1901− const handleProjectTitleKeyDown = useCallback(
1902− (event: KeyboardEvent<HTMLButtonElement>, projectId: ProjectId) => {
1903− if (event.key !== "Enter" && event.key !== " ") return;
1904− event.preventDefault();
1905− if (dragInProgressRef.current) {
1906− return;
1907− }
1908− toggleProject(projectId);
1909− },
1910− [toggleProject],
1911− );
1912−
19132762 useEffect(() => {
19142763 const onMouseDown = (event: globalThis.MouseEvent) => {
1915− if (selectedThreadIds.size === 0) return;
2764+ if (selectedThreadCount === 0) return;
19162765 const target = event.target instanceof HTMLElement ? event.target : null;
19172766 if (!shouldClearThreadSelectionOnMouseDown(target)) return;
19182767 clearSelection();
@@ -1922,7 +2771,7 @@ export default function Sidebar() { · 3 unmodified lines
19222771 return () => {
19232772 window.removeEventListener("mousedown", onMouseDown);
19242773 };
1925− }, [clearSelection, selectedThreadIds.size]);
2774+ }, [clearSelection, selectedThreadCount]);
19262775
19272776 useEffect(() => {
19282777 if (!isElectron) return;
@@ -1967,10 +2816,6 @@ export default function Sidebar() { · 38 unmodified lines
19672816 desktopUpdateState && showArm64IntelBuildWarning
19682817 ? getArm64IntelBuildWarningDescription(desktopUpdateState)
19692818 : null;
1970− const newThreadShortcutLabel =
1971− shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ??
1972− shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions);
1973−
19742819 const handleDesktopUpdateButtonClick = useCallback(() => {
19752820 const bridge = window.desktopBridge;
19762821 if (!bridge || !desktopUpdateState) return;
@@ -2033,246 +2878,82 @@ export default function Sidebar() { · 56 unmodified lines
20332878 }
20342879 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);
20352880
2036− const expandThreadListForProject = useCallback((projectId: ProjectId) => {
2881+ const expandThreadListForProject = useCallback((projectKey: string) => {
20372882 setExpandedThreadListsByProject((current) => {
2038− if (current.has(projectId)) return current;
2883+ if (current.has(projectKey)) return current;
20392884 const next = new Set(current);
2040− next.add(projectId);
2885+ next.add(projectKey);
20412886 return next;
20422887 });
20432888 }, []);
20442889
2045− const collapseThreadListForProject = useCallback((projectId: ProjectId) => {
2890+ const collapseThreadListForProject = useCallback((projectKey: string) => {
20462891 setExpandedThreadListsByProject((current) => {
2047− if (!current.has(projectId)) return current;
2892+ if (!current.has(projectKey)) return current;
20482893 const next = new Set(current);
2049− next.delete(projectId);
2894+ next.delete(projectKey);
20502895 return next;
20512896 });
20522897 }, []);
20532898
2054− const wordmark = (
2055− <div className="flex items-center gap-2">
2056− <SidebarTrigger className="shrink-0 md:hidden" />
2057− <Tooltip>
2058− <TooltipTrigger
2059− render={
2060− <Link
2061− aria-label="Go to threads"
2062− className="ml-1 flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md outline-hidden ring-ring transition-colors hover:text-foreground focus-visible:ring-2"
2063− to="/"
2064− >
2065− <T3Wordmark />
2066− <span className="truncate text-sm font-medium tracking-tight text-muted-foreground">
2067− Code
2068− </span>
2069− <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground/60">
2070− {APP_STAGE_LABEL}
2071− </span>
2072− </Link>
2073− }
2074− />
2075− <TooltipPopup side="bottom" sideOffset={2}>
2076− Version {APP_VERSION}
2077− </TooltipPopup>
2078− </Tooltip>
2079− </div>
2080− );
2081−
20822899 return (
20832900 <>
2084− {isElectron ? (
2085− <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
2086− {wordmark}
2087− </SidebarHeader>
2088− ) : (
2089− <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">
2090− {wordmark}
2091− </SidebarHeader>
2092− )}
2901+ <SidebarChromeHeader isElectron={isElectron} />
20932902
20942903 {isOnSettings ? (
20952904 <SettingsSidebarNav pathname={pathname} />
20962905 ) : (
20972906 <>
2098− <SidebarContent className="gap-0">
2099− {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
2100− <SidebarGroup className="px-2 pt-2 pb-0">
2101− <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
2102− <TriangleAlertIcon />
2103− <AlertTitle>Intel build on Apple Silicon</AlertTitle>
2104− <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
2105− {desktopUpdateButtonAction !== "none" ? (
2106− <AlertAction>
2107− <Button
2108− size="xs"
2109− variant="outline"
2110− disabled={desktopUpdateButtonDisabled}
2111− onClick={handleDesktopUpdateButtonClick}
2112− >
2113− {desktopUpdateButtonAction === "download"
2114− ? "Download ARM build"
2115− : "Install ARM build"}
2116− </Button>
2117− </AlertAction>
2118− ) : null}
2119− </Alert>
2120− </SidebarGroup>
2121− ) : null}
2122− <SidebarGroup className="px-2 py-2">
2123− <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">
2124− <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
2125− Projects
2126− </span>
2127− <div className="flex items-center gap-1">
2128− <ProjectSortMenu
2129− projectSortOrder={appSettings.sidebarProjectSortOrder}
2130− threadSortOrder={appSettings.sidebarThreadSortOrder}
2131− onProjectSortOrderChange={(sortOrder) => {
2132− updateSettings({ sidebarProjectSortOrder: sortOrder });
2133− }}
2134− onThreadSortOrderChange={(sortOrder) => {
2135− updateSettings({ sidebarThreadSortOrder: sortOrder });
2136− }}
2137− />
2138− <Tooltip>
2139− <TooltipTrigger
2140− render={
2141− <button
2142− type="button"
2143− aria-label={
2144− shouldShowProjectPathEntry ? "Cancel add project" : "Add project"
2145− }
2146− aria-pressed={shouldShowProjectPathEntry}
2147− className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
2148− onClick={handleStartAddProject}
2149− />
2150− }
2151− >
2152− <PlusIcon
2153− className={`size-3.5 transition-transform duration-150 ${
2154− shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
2155− }`}
2156− />
2157− </TooltipTrigger>
2158− <TooltipPopup side="right">
2159− {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
2160− </TooltipPopup>
2161− </Tooltip>
2162− </div>
2163− </div>
2164− {shouldShowProjectPathEntry && (
2165− <div className="mb-2 px-1">
2166− {isElectron && (
2167− <button
2168− type="button"
2169− className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
2170− onClick={() => void handlePickFolder()}
2171− disabled={isPickingFolder || isAddingProject}
2172− >
2173− <FolderIcon className="size-3.5" />
2174− {isPickingFolder ? "Picking folder..." : "Browse for folder"}
2175− </button>
2176− )}
2177− <div className="flex gap-1.5">
2178− <input
2179− ref={addProjectInputRef}
2180− className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${
2181− addProjectError
2182− ? "border-red-500/70 focus:border-red-500"
2183− : "border-border focus:border-ring"
2184− }`}
2185− placeholder="/path/to/project"
2186− value={newCwd}
2187− onChange={(event) => {
2188− setNewCwd(event.target.value);
2189− setAddProjectError(null);
2190− }}
2191− onKeyDown={(event) => {
2192− if (event.key === "Enter") handleAddProject();
2193− if (event.key === "Escape") {
2194− setAddingProject(false);
2195− setAddProjectError(null);
2196− }
2197− }}
2198− autoFocus
2199− />
2200− <button
2201− type="button"
2202− className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60"
2203− onClick={handleAddProject}
2204− disabled={!canAddProject}
2205− >
2206− {isAddingProject ? "Adding..." : "Add"}
2207− </button>
2208− </div>
2209− {addProjectError && (
2210− <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400">
2211− {addProjectError}
2212− </p>
2213− )}
2214− </div>
2215− )}
2216−
2217− {isManualProjectSorting ? (
2218− <DndContext
2219− sensors={projectDnDSensors}
2220− collisionDetection={projectCollisionDetection}
2221− modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
2222− onDragStart={handleProjectDragStart}
2223− onDragEnd={handleProjectDragEnd}
2224− onDragCancel={handleProjectDragCancel}
2225− >
2226− <SidebarMenu>
2227− <SortableContext
2228− items={renderedProjects.map((renderedProject) => renderedProject.project.id)}
2229− strategy={verticalListSortingStrategy}
2230− >
2231− {renderedProjects.map((renderedProject) => (
2232− <SortableProjectItem
2233− key={renderedProject.project.id}
2234− projectId={renderedProject.project.id}
2235− >
2236− {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)}
2237− </SortableProjectItem>
2238− ))}
2239− </SortableContext>
2240− </SidebarMenu>
2241− </DndContext>
2242− ) : (
2243− <SidebarMenu ref={attachProjectListAutoAnimateRef}>
2244− {renderedProjects.map((renderedProject) => (
2245− <SidebarMenuItem key={renderedProject.project.id} className="rounded-md">
2246− {renderProjectItem(renderedProject, null)}
2247− </SidebarMenuItem>
2248− ))}
2249− </SidebarMenu>
2250− )}
2251−
2252− {projects.length === 0 && !shouldShowProjectPathEntry && (
2253− <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
2254− No projects yet
2255− </div>
2256− )}
2257− </SidebarGroup>
2258− </SidebarContent>
2907+ <SidebarProjectsContent
2908+ showArm64IntelBuildWarning={showArm64IntelBuildWarning}
2909+ arm64IntelBuildWarningDescription={arm64IntelBuildWarningDescription}
2910+ desktopUpdateButtonAction={desktopUpdateButtonAction}
2911+ desktopUpdateButtonDisabled={desktopUpdateButtonDisabled}
2912+ handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
2913+ projectSortOrder={appSettings.sidebarProjectSortOrder}
2914+ threadSortOrder={appSettings.sidebarThreadSortOrder}
2915+ updateSettings={updateSettings}
2916+ shouldShowProjectPathEntry={shouldShowProjectPathEntry}
2917+ handleStartAddProject={handleStartAddProject}
2918+ isElectron={isElectron}
2919+ isPickingFolder={isPickingFolder}
2920+ isAddingProject={isAddingProject}
2921+ handlePickFolder={handlePickFolder}
2922+ addProjectInputRef={addProjectInputRef}
2923+ addProjectError={addProjectError}
2924+ newCwd={newCwd}
2925+ setNewCwd={setNewCwd}
2926+ setAddProjectError={setAddProjectError}
2927+ handleAddProject={handleAddProject}
2928+ setAddingProject={setAddingProject}
2929+ canAddProject={canAddProject}
2930+ isManualProjectSorting={isManualProjectSorting}
2931+ projectDnDSensors={projectDnDSensors}
2932+ projectCollisionDetection={projectCollisionDetection}
2933+ handleProjectDragStart={handleProjectDragStart}
2934+ handleProjectDragEnd={handleProjectDragEnd}
2935+ handleProjectDragCancel={handleProjectDragCancel}
2936+ handleNewThread={handleNewThread}
2937+ archiveThread={archiveThread}
2938+ deleteThread={deleteThread}
2939+ sortedProjects={sortedProjects}
2940+ expandedThreadListsByProject={expandedThreadListsByProject}
2941+ activeRouteProjectKey={activeRouteProjectKey}
2942+ routeThreadKey={routeThreadKey}
2943+ newThreadShortcutLabel={newThreadShortcutLabel}
2944+ threadJumpLabelByKey={visibleThreadJumpLabelByKey}
2945+ attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2946+ expandThreadListForProject={expandThreadListForProject}
2947+ collapseThreadListForProject={collapseThreadListForProject}
2948+ dragInProgressRef={dragInProgressRef}
2949+ suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
2950+ suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
2951+ attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}
2952+ projectsLength={projects.length}
2953+ />
22592954
22602955 <SidebarSeparator />
2261− <SidebarFooter className="p-2">
2262− <SidebarUpdatePill />
2263− <SidebarMenu>
2264− <SidebarMenuItem>
2265− <SidebarMenuButton
2266− size="sm"
2267− className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
2268− onClick={() => void navigate({ to: "/settings" })}
2269− >
2270− <SettingsIcon className="size-3.5" />
2271− <span className="text-xs">Settings</span>
2272− </SidebarMenuButton>
2273− </SidebarMenuItem>
2274− </SidebarMenu>
2275− </SidebarFooter>
2956+ <SidebarChromeFooter />
22762957 </>
22772958 )}
22782959 </>
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+247−0
@@ -0,0 +1,247 @@
1+import "../index.css";
2+
3+import { scopeThreadRef } from "@t3tools/client-runtime";
4+import { ThreadId } from "@t3tools/contracts";
5+import { afterEach, describe, expect, it, vi } from "vitest";
6+import { render } from "vitest-browser-react";
7+
8+const {
9+ terminalConstructorSpy,
10+ terminalDisposeSpy,
11+ fitAddonFitSpy,
12+ fitAddonLoadSpy,
13+ environmentApiById,
14+ readEnvironmentApiMock,
15+ readLocalApiMock,
16+} = vi.hoisted(() => ({
17+ terminalConstructorSpy: vi.fn(),
18+ terminalDisposeSpy: vi.fn(),
19+ fitAddonFitSpy: vi.fn(),
20+ fitAddonLoadSpy: vi.fn(),
21+ environmentApiById: new Map<string, { terminal: { open: ReturnType<typeof vi.fn> } }>(),
22+ readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)),
23+ readLocalApiMock: vi.fn<
24+ () =>
25+ | {
26+ contextMenu: { show: ReturnType<typeof vi.fn> };
27+ shell: { openExternal: ReturnType<typeof vi.fn> };
28+ }
29+ | undefined
30+ >(() => ({
31+ contextMenu: { show: vi.fn(async () => null) },
32+ shell: { openExternal: vi.fn(async () => undefined) },
33+ })),
34+}));
35+
36+vi.mock("@xterm/addon-fit", () => ({
37+ FitAddon: class MockFitAddon {
38+ fit = fitAddonFitSpy;
39+ },
40+}));
41+
42+vi.mock("@xterm/xterm", () => ({
43+ Terminal: class MockTerminal {
44+ cols = 80;
45+ rows = 24;
46+ options: { theme?: unknown } = {};
47+ buffer = {
48+ active: {
49+ viewportY: 0,
50+ baseY: 0,
51+ getLine: vi.fn(() => null),
52+ },
53+ };
54+
55+ constructor(options: unknown) {
56+ terminalConstructorSpy(options);
57+ }
58+
59+ loadAddon(addon: unknown) {
60+ fitAddonLoadSpy(addon);
61+ }
62+
63+ open() {}
64+
65+ write() {}
66+
67+ clear() {}
68+
69+ clearSelection() {}
70+
71+ focus() {}
72+
73+ refresh() {}
74+
75+ scrollToBottom() {}
76+
77+ hasSelection() {
78+ return false;
79+ }
80+
81+ getSelection() {
82+ return "";
83+ }
84+
85+ getSelectionPosition() {
86+ return null;
87+ }
88+
89+ attachCustomKeyEventHandler() {
90+ return true;
91+ }
92+
93+ registerLinkProvider() {
94+ return { dispose: vi.fn() };
95+ }
96+
97+ onData() {
98+ return { dispose: vi.fn() };
99+ }
100+
101+ onSelectionChange() {
102+ return { dispose: vi.fn() };
103+ }
104+
105+ dispose() {
106+ terminalDisposeSpy();
107+ }
108+ },
109+}));
110+
111+vi.mock("~/environmentApi", () => ({
112+ readEnvironmentApi: readEnvironmentApiMock,
113+}));
114+
115+vi.mock("~/localApi", () => ({
116+ readLocalApi: readLocalApiMock,
117+}));
118+
119+import { TerminalViewport } from "./ThreadTerminalDrawer";
120+
121+const THREAD_ID = ThreadId.makeUnsafe("thread-terminal-browser");
122+
123+function createEnvironmentApi() {
124+ return {
125+ terminal: {
126+ open: vi.fn(async () => ({
127+ threadId: THREAD_ID,
128+ terminalId: "default",
129+ cwd: "/repo/project",
130+ worktreePath: null,
131+ status: "running" as const,
132+ pid: 123,
133+ history: "",
134+ exitCode: null,
135+ exitSignal: null,
136+ updatedAt: "2026-04-07T00:00:00.000Z",
137+ })),
138+ write: vi.fn(async () => undefined),
139+ resize: vi.fn(async () => undefined),
140+ },
141+ };
142+}
143+
144+async function mountTerminalViewport(props: { threadRef: ReturnType<typeof scopeThreadRef> }) {
145+ const host = document.createElement("div");
146+ host.style.width = "800px";
147+ host.style.height = "400px";
148+ document.body.append(host);
149+
150+ const screen = await render(
151+ <TerminalViewport
152+ threadRef={props.threadRef}
153+ threadId={THREAD_ID}
154+ terminalId="default"
155+ terminalLabel="Terminal"
156+ cwd="/repo/project"
157+ onSessionExited={() => undefined}
158+ onAddTerminalContext={() => undefined}
159+ focusRequestId={0}
160+ autoFocus={false}
161+ resizeEpoch={0}
162+ drawerHeight={320}
163+ />,
164+ { container: host },
165+ );
166+
167+ return {
168+ rerender: async (nextProps: { threadRef: ReturnType<typeof scopeThreadRef> }) => {
169+ await screen.rerender(
170+ <TerminalViewport
171+ threadRef={nextProps.threadRef}
172+ threadId={THREAD_ID}
173+ terminalId="default"
174+ terminalLabel="Terminal"
175+ cwd="/repo/project"
176+ onSessionExited={() => undefined}
177+ onAddTerminalContext={() => undefined}
178+ focusRequestId={0}
179+ autoFocus={false}
180+ resizeEpoch={0}
181+ drawerHeight={320}
182+ />,
183+ );
184+ },
185+ cleanup: async () => {
186+ await screen.unmount();
187+ host.remove();
188+ },
189+ };
190+}
191+
192+describe("TerminalViewport", () => {
193+ afterEach(() => {
194+ environmentApiById.clear();
195+ readEnvironmentApiMock.mockClear();
196+ readLocalApiMock.mockClear();
197+ terminalConstructorSpy.mockClear();
198+ terminalDisposeSpy.mockClear();
199+ fitAddonFitSpy.mockClear();
200+ fitAddonLoadSpy.mockClear();
201+ });
202+
203+ it("does not create a terminal when APIs are unavailable", async () => {
204+ readEnvironmentApiMock.mockReturnValueOnce(undefined);
205+ readLocalApiMock.mockReturnValueOnce(undefined);
206+
207+ const mounted = await mountTerminalViewport({
208+ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
209+ });
210+
211+ try {
212+ await vi.waitFor(() => {
213+ expect(terminalConstructorSpy).not.toHaveBeenCalled();
214+ });
215+ } finally {
216+ await mounted.cleanup();
217+ }
218+ });
219+
220+ it("reopens the terminal when the scoped thread reference changes", async () => {
221+ const environmentA = createEnvironmentApi();
222+ const environmentB = createEnvironmentApi();
223+ environmentApiById.set("environment-a", environmentA);
224+ environmentApiById.set("environment-b", environmentB);
225+
226+ const mounted = await mountTerminalViewport({
227+ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
228+ });
229+
230+ try {
231+ await vi.waitFor(() => {
232+ expect(environmentA.terminal.open).toHaveBeenCalledTimes(1);
233+ });
234+
235+ await mounted.rerender({
236+ threadRef: scopeThreadRef("environment-b" as never, THREAD_ID),
237+ });
238+
239+ await vi.waitFor(() => {
240+ expect(environmentB.terminal.open).toHaveBeenCalledTimes(1);
241+ });
242+ expect(terminalDisposeSpy).toHaveBeenCalledTimes(1);
243+ } finally {
244+ await mounted.cleanup();
245+ }
246+ });
247+});
apps/web/src/components/ThreadTerminalDrawer.tsx+22−14
@@ -1,6 +1,7 @@
11 import { FitAddon } from "@xterm/addon-fit";
22 import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";
33 import {
4+ type ScopedThreadRef,
45 type TerminalEvent,
56 type TerminalSessionSnapshot,
67 type ThreadId,
@@ -31,7 +32,8 @@ import { · 24 unmodified lines
3132 MAX_TERMINALS_PER_GROUP,
3233 type ThreadTerminalGroup,
3334 } from "../types";
34−import { readNativeApi } from "~/nativeApi";
35+import { readEnvironmentApi } from "~/environmentApi";
36+import { readLocalApi } from "~/localApi";
3537 import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore";
3638
3739 const MIN_DRAWER_HEIGHT = 180;
@@ -208,6 +210,7 @@ export function shouldHandleTerminalSelectionMouseUp( · 170 unmodified lines
208210 }
209211
210212 interface TerminalViewportProps {
213+ threadRef: ScopedThreadRef;
211214 threadId: ThreadId;
212215 terminalId: string;
213216 terminalLabel: string;
@@ -222,7 +225,8 @@ interface TerminalViewportProps { · 8 unmodified lines
222225 drawerHeight: number;
223226 }
224227
225−function TerminalViewport({
228+export function TerminalViewport({
229+ threadRef,
226230 threadId,
227231 terminalId,
228232 terminalLabel,
@@ -260,6 +264,9 @@ function TerminalViewport({ · 31 unmodified lines
260264 if (!mount) return;
261265
262266 let disposed = false;
267+ const api = readEnvironmentApi(threadRef.environmentId);
268+ const localApi = readLocalApi();
269+ if (!api || !localApi) return;
263270
264271 const fitAddon = new FitAddon();
265272 const terminal = new Terminal({
@@ -277,9 +284,6 @@ function TerminalViewport({ · 11 unmodified lines
277284 terminalRef.current = terminal;
278285 fitAddonRef.current = fitAddon;
279286
280− const api = readNativeApi();
281− if (!api) return;
282−
283287 const clearSelectionAction = () => {
284288 selectionActionRequestIdRef.current += 1;
285289 if (selectionActionTimerRef.current !== null) {
@@ -340,7 +344,7 @@ function TerminalViewport({ · 54 unmodified lines
340344 const requestId = ++selectionActionRequestIdRef.current;
341345 selectionActionOpenRef.current = true;
342346 try {
343− const clicked = await api.contextMenu.show(
347+ const clicked = await localApi.contextMenu.show(
344348 [{ id: "add-to-chat", label: "Add to chat" }],
345349 nextAction.position,
346350 );
@@ -416,7 +420,7 @@ function TerminalViewport({ · 69 unmodified lines
416420 if (!latestTerminal) return;
417421
418422 if (match.kind === "url") {
419− void api.shell.openExternal(match.text).catch((error) => {
423+ void localApi.shell.openExternal(match.text).catch((error: unknown) => {
420424 writeSystemMessage(
421425 latestTerminal,
422426 error instanceof Error ? error.message : "Unable to open link",
@@ -426,7 +430,7 @@ function TerminalViewport({ · 3 unmodified lines
426430 }
427431
428432 const target = resolvePathLinkTarget(match.text, cwd);
429− void openInPreferredEditor(api, target).catch((error) => {
433+ void openInPreferredEditor(localApi, target).catch((error) => {
430434 writeSystemMessage(
431435 latestTerminal,
432436 error instanceof Error ? error.message : "Unable to open path",
@@ -573,12 +577,12 @@ function TerminalViewport({ · 140 unmodified lines
573577 const previousLastEntryId =
574578 selectTerminalEventEntries(
575579 previousState.terminalEventEntriesByKey,
576− threadId,
580+ threadRef,
577581 terminalId,
578582 ).at(-1)?.id ?? 0;
579583 const nextEntries = selectTerminalEventEntries(
580584 state.terminalEventEntriesByKey,
581− threadId,
585+ threadRef,
582586 terminalId,
583587 );
584588 const nextLastEntryId = nextEntries.at(-1)?.id ?? 0;
@@ -608,7 +612,7 @@ function TerminalViewport({ · 23 unmodified lines
608612 writeTerminalSnapshot(activeTerminal, snapshot);
609613 const bufferedEntries = selectTerminalEventEntries(
610614 useTerminalStateStore.getState().terminalEventEntriesByKey,
611− threadId,
615+ threadRef,
612616 terminalId,
613617 );
614618 const replayEntries = selectTerminalEventEntriesAfterSnapshot(
@@ -677,7 +681,7 @@ function TerminalViewport({ · 62 unmodified lines
677681 // autoFocus is intentionally omitted;
678682 // it is only read at mount time and must not trigger terminal teardown/recreation.
679683 // eslint-disable-next-line react-hooks/exhaustive-deps
680− }, [cwd, runtimeEnv, terminalId, threadId]);
684+ }, [cwd, runtimeEnv, terminalId, threadId, threadRef]);
681685
682686 useEffect(() => {
683687 if (!autoFocus) return;
@@ -692,7 +696,7 @@ function TerminalViewport({ · 8 unmodified lines
692696 }, [autoFocus, focusRequestId]);
693697
694698 useEffect(() => {
695− const api = readNativeApi();
699+ const api = readEnvironmentApi(threadRef.environmentId);
696700 const terminal = terminalRef.current;
697701 const fitAddon = fitAddonRef.current;
698702 if (!api || !terminal || !fitAddon) return;
@@ -714,13 +718,14 @@ function TerminalViewport({ · 15 unmodified lines
714718 return () => {
715719 window.cancelAnimationFrame(frame);
716720 };
717− }, [drawerHeight, resizeEpoch, terminalId, threadId]);
721+ }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]);
718722 return (
719723 <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-[4px]" />
720724 );
721725 }
722726
723727 interface ThreadTerminalDrawerProps {
728+ threadRef: ScopedThreadRef;
724729 threadId: ThreadId;
725730 cwd: string;
726731 worktreePath?: string | null;
@@ -773,6 +778,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA · 46 unmodified lines
773778 }
774779
775780 export default function ThreadTerminalDrawer({
781+ threadRef,
776782 threadId,
777783 cwd,
778784 worktreePath,
@@ -1098,6 +1104,7 @@ export default function ThreadTerminalDrawer({ · 319 unmodified lines
10981104 >
10991105 <div className="h-full p-1">
11001106 <TerminalViewport
1107+ threadRef={threadRef}
11011108 threadId={threadId}
11021109 terminalId={terminalId}
11031110 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
@@ -1119,6 +1126,7 @@ export default function ThreadTerminalDrawer({ · 15 unmodified lines
11191126 <div className="h-full p-1">
11201127 <TerminalViewport
11211128 key={resolvedActiveTerminalId}
1129+ threadRef={threadRef}
11221130 threadId={threadId}
11231131 terminalId={resolvedActiveTerminalId}
11241132 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
apps/web/src/components/chat/ChatHeader.tsx+10−1
@@ -1,9 +1,11 @@
11 import {
2+ type EnvironmentId,
23 type EditorId,
34 type ProjectScript,
45 type ResolvedKeybindingsConfig,
56 type ThreadId,
67 } from "@t3tools/contracts";
8+import { scopeThreadRef } from "@t3tools/client-runtime";
79 import { memo } from "react";
810 import GitActionsControl from "../GitActionsControl";
911 import { DiffIcon, TerminalSquareIcon } from "lucide-react";
@@ -15,6 +17,7 @@ import { SidebarTrigger } from "../ui/sidebar"; · 5 unmodified lines
1517 import { OpenInPicker } from "./OpenInPicker";
1618
1719 interface ChatHeaderProps {
20+ activeThreadEnvironmentId: EnvironmentId;
1821 activeThreadId: ThreadId;
1922 activeThreadTitle: string;
2023 activeProjectName: string | undefined;
@@ -39,6 +42,7 @@ interface ChatHeaderProps { · 18 unmodified lines
3942 }
4043
4144 export const ChatHeader = memo(function ChatHeader({
45+ activeThreadEnvironmentId,
4246 activeThreadId,
4347 activeThreadTitle,
4448 activeProjectName,
@@ -101,7 +105,12 @@ export const ChatHeader = memo(function ChatHeader({ · 56 unmodified lines
101105 openInCwd={openInCwd}
102106 />
103107 )}
104− {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}
108+ {activeProjectName && (
109+ <GitActionsControl
110+ gitCwd={gitCwd}
111+ activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)}
112+ />
113+ )}
105114 <Tooltip>
106115 <TooltipTrigger
107116 render={
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+35−27
@@ -1,4 +1,10 @@
1−import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts";
1+import {
2+ DEFAULT_MODEL_BY_PROVIDER,
3+ EnvironmentId,
4+ ModelSelection,
5+ ThreadId,
6+} from "@t3tools/contracts";
7+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
28 import "../../index.css";
39
410 import { page } from "vitest/browser";
@@ -9,35 +15,37 @@ import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; · 4 unmodified lines
915 import { TraitsMenuContent } from "./TraitsPicker";
1016 import { useComposerDraftStore } from "../../composerDraftStore";
1117
18+const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
19+
1220 async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) {
1321 const threadId = ThreadId.makeUnsafe("thread-compact-menu");
22+ const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);
23+ const threadKey = scopedThreadKey(threadRef);
1424 const provider = props?.modelSelection?.provider ?? "claudeAgent";
15− const draftsByThreadId = {} as ReturnType<
16− typeof useComposerDraftStore.getState
17− >["draftsByThreadId"];
1825 const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider];
1926
20− draftsByThreadId[threadId] = {
21− prompt: props?.prompt ?? "",
22− images: [],
23− nonPersistedImageIds: [],
24− persistedAttachments: [],
25− terminalContexts: [],
26− modelSelectionByProvider: {
27− [provider]: {
28− provider,
29− model,
30− ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}),
27+ useComposerDraftStore.setState({
28+ draftsByThreadKey: {
29+ [threadKey]: {
30+ prompt: props?.prompt ?? "",
31+ images: [],
32+ nonPersistedImageIds: [],
33+ persistedAttachments: [],
34+ terminalContexts: [],
35+ modelSelectionByProvider: {
36+ [provider]: {
37+ provider,
38+ model,
39+ ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}),
40+ },
41+ },
42+ activeProvider: provider,
43+ runtimeMode: null,
44+ interactionMode: null,
3145 },
3246 },
33− activeProvider: provider,
34− runtimeMode: null,
35− interactionMode: null,
36− };
37− useComposerDraftStore.setState({
38− draftsByThreadId,
39− draftThreadsByThreadId: {},
40− projectDraftThreadIdByProjectId: {},
47+ draftThreadsByThreadKey: {},
48+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
4149 });
4250 const host = document.createElement("div");
4351 document.body.append(host);
@@ -121,7 +129,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str · 77 unmodified lines
121129 <TraitsMenuContent
122130 provider={provider}
123131 models={models}
124− threadId={threadId}
132+ threadRef={threadRef}
125133 model={model}
126134 prompt={props?.prompt ?? ""}
127135 modelOptions={providerOptions}
@@ -150,9 +158,9 @@ describe("CompactComposerControlsMenu", () => { · 22 unmodified lines
150158 afterEach(() => {
151159 document.body.innerHTML = "";
152160 useComposerDraftStore.setState({
153− draftsByThreadId: {},
154− draftThreadsByThreadId: {},
155− projectDraftThreadIdByProjectId: {},
161+ draftsByThreadKey: {},
162+ draftThreadsByThreadKey: {},
163+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
156164 stickyModelSelectionByProvider: {},
157165 });
158166 });
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+20−18
@@ -1,5 +1,5 @@
11 import { type ApprovalRequestId } from "@t3tools/contracts";
2−import { memo, useCallback, useEffect, useRef } from "react";
2+import { memo, useEffect, useEffectEvent, useRef } from "react";
33 import { type PendingUserInput } from "../../session-logic";
44 import {
55 derivePendingUserInputProgress,
@@ -60,6 +60,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 54 unmodified lines
6060 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
6161 const activeQuestion = progress.activeQuestion;
6262 const autoAdvanceTimerRef = useRef<number | null>(null);
63+ const onAdvanceRef = useRef(onAdvance);
64+
65+ useEffect(() => {
66+ onAdvanceRef.current = onAdvance;
67+ }, [onAdvance]);
6368
6469 // Clear auto-advance timer on unmount
6570 useEffect(() => {
@@ -70,22 +75,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 4 unmodified lines
7075 };
7176 }, []);
7277
73− const handleOptionSelection = useCallback(
74− (questionId: string, optionLabel: string) => {
75− onToggleOption(questionId, optionLabel);
76− if (activeQuestion?.multiSelect) {
77− return;
78− }
79− if (autoAdvanceTimerRef.current !== null) {
80− window.clearTimeout(autoAdvanceTimerRef.current);
81− }
82− autoAdvanceTimerRef.current = window.setTimeout(() => {
83− autoAdvanceTimerRef.current = null;
84− onAdvance();
85− }, 200);
86− },
87− [activeQuestion?.multiSelect, onAdvance, onToggleOption],
88− );
78+ const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => {
79+ onToggleOption(questionId, optionLabel);
80+ if (activeQuestion?.multiSelect) {
81+ return;
82+ }
83+ if (autoAdvanceTimerRef.current !== null) {
84+ window.clearTimeout(autoAdvanceTimerRef.current);
85+ }
86+ autoAdvanceTimerRef.current = window.setTimeout(() => {
87+ autoAdvanceTimerRef.current = null;
88+ onAdvanceRef.current();
89+ }, 200);
90+ });
8991
9092 // Keyboard shortcut: number keys 1-9 select corresponding options when focus is
9193 // outside editable fields. Multi-select prompts toggle options in place; single-
@@ -112,7 +114,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 20 unmodified lines
112114 };
113115 document.addEventListener("keydown", handler);
114116 return () => document.removeEventListener("keydown", handler);
115− }, [activeQuestion, handleOptionSelection, isResponding]);
117+ }, [activeQuestion, isResponding]);
116118
117119 if (!activeQuestion) {
118120 return null;
apps/web/src/components/chat/MessagesTimeline.test.tsx+4−0
@@ -42,6 +42,8 @@ beforeAll(() => { · 41 unmodified lines
4242 });
4343 });
4444
45+const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never;
46+
4547 describe("MessagesTimeline", () => {
4648 it("renders inline terminal labels with the composer chip UI", async () => {
4749 const { MessagesTimeline } = await import("./MessagesTimeline");
@@ -85,6 +87,7 @@ describe("MessagesTimeline", () => { · 37 unmodified lines
8587 onRevertUserMessage={() => {}}
8688 isRevertingCheckpoint={false}
8789 onImageExpand={() => {}}
90+ activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
8891 markdownCwd={undefined}
8992 resolvedTheme="light"
9093 timestampFormat="locale"
@@ -130,6 +133,7 @@ describe("MessagesTimeline", () => { · 39 unmodified lines
130133 onRevertUserMessage={() => {}}
131134 isRevertingCheckpoint={false}
132135 onImageExpand={() => {}}
136+ activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
133137 markdownCwd={undefined}
134138 resolvedTheme="light"
135139 timestampFormat="locale"
apps/web/src/components/chat/MessagesTimeline.tsx+4−1
@@ -1,4 +1,4 @@
1−import { type MessageId, type TurnId } from "@t3tools/contracts";
1+import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts";
22 import {
33 memo,
44 useCallback,
@@ -82,6 +82,7 @@ interface MessagesTimelineProps { · 77 unmodified lines
8282 onRevertUserMessage: (messageId: MessageId) => void;
8383 isRevertingCheckpoint: boolean;
8484 onImageExpand: (preview: ExpandedImagePreview) => void;
85+ activeThreadEnvironmentId: EnvironmentId;
8586 markdownCwd: string | undefined;
8687 resolvedTheme: "light" | "dark";
8788 timestampFormat: TimestampFormat;
@@ -117,6 +118,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 29 unmodified lines
117118 onRevertUserMessage,
118119 isRevertingCheckpoint,
119120 onImageExpand,
121+ activeThreadEnvironmentId,
120122 markdownCwd,
121123 resolvedTheme,
122124 timestampFormat,
@@ -531,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 408 unmodified lines
531533 <div className="min-w-0 px-1 py-0.5">
532534 <ProposedPlanCard
533535 planMarkdown={row.proposedPlan.planMarkdown}
536+ environmentId={activeThreadEnvironmentId}
534537 cwd={markdownCwd}
535538 workspaceRoot={workspaceRoot}
536539 />
apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx+23−6
@@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = { · 18 unmodified lines
1919 height: 1_100,
2020 };
2121 const MARKDOWN_CWD = "/repo/project";
22+const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never;
2223
2324 interface RowMeasurement {
2425 actualHeightPx: number;
@@ -31,7 +32,10 @@ interface RowMeasurement { · 6 unmodified lines
3132 interface VirtualizationScenario {
3233 name: string;
3334 targetRowId: string;
34− props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;
35+ props: Omit<
36+ ComponentProps<typeof MessagesTimeline>,
37+ "scrollContainer" | "activeThreadEnvironmentId"
38+ >;
3539 maxEstimateDeltaPx: number;
3640 }
3741
@@ -48,7 +52,10 @@ interface VirtualizerSnapshot { · 10 unmodified lines
4852 }
4953
5054 function MessagesTimelineBrowserHarness(
51− props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">,
55+ props: Omit<
56+ ComponentProps<typeof MessagesTimeline>,
57+ "scrollContainer" | "activeThreadEnvironmentId"
58+ >,
5259 ) {
5360 const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null);
5461 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>(
@@ -73,6 +80,7 @@ function MessagesTimelineBrowserHarness( · 18 unmodified lines
7380 >
7481 <MessagesTimeline
7582 {...props}
83+ activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
7684 scrollContainer={scrollContainer}
7785 expandedWorkGroups={expandedWorkGroups}
7886 onToggleWorkGroup={handleToggleWorkGroup}
@@ -143,7 +151,7 @@ function createBaseTimelineProps(input: { · 64 unmodified lines
143151 completionDividerBeforeEntryId?: string | null;
144152 turnDiffSummaryByAssistantMessageId?: Map<MessageId, TurnDiffSummary>;
145153 onVirtualizerSnapshot?: ComponentProps<typeof MessagesTimeline>["onVirtualizerSnapshot"];
146−}): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer"> {
154+}): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer" | "activeThreadEnvironmentId"> {
147155 return {
148156 hasMessages: true,
149157 isWorking: false,
@@ -481,7 +489,10 @@ async function waitForElement<T extends Element>( · 331 unmodified lines
481489
482490 async function measureTimelineRow(input: {
483491 host: HTMLElement;
484− props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;
492+ props: Omit<
493+ ComponentProps<typeof MessagesTimeline>,
494+ "scrollContainer" | "activeThreadEnvironmentId"
495+ >;
485496 targetRowId: string;
486497 }): Promise<RowMeasurement> {
487498 const scrollContainer = await waitForElement(
@@ -550,7 +561,10 @@ async function measureTimelineRow(input: { · 62 unmodified lines
550561 }
551562
552563 async function mountMessagesTimeline(input: {
553− props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;
564+ props: Omit<
565+ ComponentProps<typeof MessagesTimeline>,
566+ "scrollContainer" | "activeThreadEnvironmentId"
567+ >;
554568 viewport?: { width: number; height: number };
555569 }) {
556570 const viewport = input.viewport ?? DEFAULT_VIEWPORT;
@@ -576,7 +590,10 @@ async function mountMessagesTimeline(input: { · 19 unmodified lines
576590 return {
577591 host,
578592 rerender: async (
579− nextProps: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">,
593+ nextProps: Omit<
594+ ComponentProps<typeof MessagesTimeline>,
595+ "scrollContainer" | "activeThreadEnvironmentId"
596+ >,
580597 ) => {
581598 await screen.rerender(<MessagesTimelineBrowserHarness {...nextProps} />);
582599 await waitForLayout();
apps/web/src/components/chat/OpenInPicker.tsx+3−3
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
1818 Zed,
1919 } from "../Icons";
2020 import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";
21−import { readNativeApi } from "~/nativeApi";
21+import { readLocalApi } from "~/localApi";
2222
2323 const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => {
2424 const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [
@@ -93,7 +93,7 @@ export const OpenInPicker = memo(function OpenInPicker({ · 68 unmodified lines
9393
9494 const openInEditor = useCallback(
9595 (editorId: EditorId | null) => {
96− const api = readNativeApi();
96+ const api = readLocalApi();
9797 if (!api || !openInCwd) return;
9898 const editor = editorId ?? preferredEditor;
9999 if (!editor) return;
@@ -110,7 +110,7 @@ export const OpenInPicker = memo(function OpenInPicker({ · 10 unmodified lines
110110
111111 useEffect(() => {
112112 const handler = (e: globalThis.KeyboardEvent) => {
113− const api = readNativeApi();
113+ const api = readLocalApi();
114114 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
115115 if (!api || !openInCwd) return;
116116 if (!preferredEditor) return;
apps/web/src/components/chat/ProposedPlanCard.tsx+5−2
@@ -1,4 +1,5 @@
11 import { memo, useState, useId } from "react";
2+import type { EnvironmentId } from "@t3tools/contracts";
23 import {
34 buildCollapsedProposedPlanPreviewMarkdown,
45 buildProposedPlanMarkdownFilename,
@@ -24,15 +25,17 @@ import { · 19 unmodified lines
2425 DialogTitle,
2526 } from "../ui/dialog";
2627 import { toastManager } from "../ui/toast";
27−import { readNativeApi } from "~/nativeApi";
28+import { readEnvironmentApi } from "~/environmentApi";
2829 import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
2930
3031 export const ProposedPlanCard = memo(function ProposedPlanCard({
3132 planMarkdown,
33+ environmentId,
3234 cwd,
3335 workspaceRoot,
3436 }: {
3537 planMarkdown: string;
38+ environmentId: EnvironmentId;
3639 cwd: string | undefined;
3740 workspaceRoot: string | undefined;
3841 }) {
@@ -82,7 +85,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ · 43 unmodified lines
8285 };
8386
8487 const handleSaveToWorkspace = () => {
85− const api = readNativeApi();
88+ const api = readEnvironmentApi(environmentId);
8689 const relativePath = savePath.trim();
8790 if (!api || !workspaceRoot) {
8891 return;
apps/web/src/components/chat/TraitsPicker.browser.tsx+30−24
@@ -6,10 +6,11 @@ import { · 5 unmodified lines
66 CodexModelOptions,
77 DEFAULT_MODEL_BY_PROVIDER,
88 DEFAULT_SERVER_SETTINGS,
9− ProjectId,
9+ EnvironmentId,
1010 type ServerProvider,
1111 ThreadId,
1212 } from "@t3tools/contracts";
13+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
1314 import { page } from "vitest/browser";
1415 import { useCallback } from "react";
1516 import { afterEach, describe, expect, it, vi } from "vitest";
@@ -27,7 +28,13 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; · 11 unmodified lines
2728
2829 // ── Claude TraitsPicker tests ─────────────────────────────────────────
2930
31+const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
3032 const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits");
33+const CLAUDE_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CLAUDE_THREAD_ID);
34+const CLAUDE_THREAD_KEY = scopedThreadKey(CLAUDE_THREAD_REF);
35+const CODEX_THREAD_ID = ThreadId.makeUnsafe("thread-codex-traits");
36+const CODEX_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CODEX_THREAD_ID);
37+const CODEX_THREAD_KEY = scopedThreadKey(CODEX_THREAD_REF);
3138 const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
3239 {
3340 provider: "codex",
@@ -120,10 +127,10 @@ function ClaudeTraitsPickerHarness(props: { · 86 unmodified lines
120127 fallbackModelSelection: ModelSelection | null;
121128 triggerVariant?: "ghost" | "outline";
122129 }) {
123− const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt;
130+ const prompt = useComposerThreadDraft(CLAUDE_THREAD_REF).prompt;
124131 const setPrompt = useComposerDraftStore((store) => store.setPrompt);
125132 const { modelOptions, selectedModel } = useEffectiveComposerModelState({
126− threadId: CLAUDE_THREAD_ID,
133+ threadRef: CLAUDE_THREAD_REF,
127134 providers: TEST_PROVIDERS,
128135 selectedProvider: "claudeAgent",
129136 threadModelSelection: props.fallbackModelSelection,
@@ -135,7 +142,7 @@ function ClaudeTraitsPickerHarness(props: { · 5 unmodified lines
135142 });
136143 const handlePromptChange = useCallback(
137144 (nextPrompt: string) => {
138− setPrompt(CLAUDE_THREAD_ID, nextPrompt);
145+ setPrompt(CLAUDE_THREAD_REF, nextPrompt);
139146 },
140147 [setPrompt],
141148 );
@@ -144,7 +151,7 @@ function ClaudeTraitsPickerHarness(props: { · 2 unmodified lines
144151 <TraitsPicker
145152 provider="claudeAgent"
146153 models={TEST_PROVIDERS[1]!.models}
147− threadId={CLAUDE_THREAD_ID}
154+ threadRef={CLAUDE_THREAD_REF}
148155 model={selectedModel ?? props.model}
149156 prompt={prompt}
150157 modelOptions={modelOptions?.claudeAgent}
@@ -168,8 +175,8 @@ async function mountClaudePicker(props?: { · 17 unmodified lines
168175 }) {
169176 const model = props?.model ?? "claude-opus-4-6";
170177 const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined;
171− const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = {
172− [CLAUDE_THREAD_ID]: {
178+ const draftsByThreadKey: Record<string, ComposerThreadDraftState> = {
179+ [CLAUDE_THREAD_KEY]: {
173180 prompt: props?.prompt ?? "",
174181 images: [],
175182 nonPersistedImageIds: [],
@@ -192,9 +199,9 @@ async function mountClaudePicker(props?: { · 16 unmodified lines
192199 },
193200 };
194201 useComposerDraftStore.setState({
195− draftsByThreadId,
196− draftThreadsByThreadId: {},
197− projectDraftThreadIdByProjectId: {},
202+ draftsByThreadKey,
203+ draftThreadsByThreadKey: {},
204+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
198205 });
199206 const host = document.createElement("div");
200207 document.body.append(host);
@@ -230,9 +237,9 @@ describe("TraitsPicker (Claude)", () => { · 29 unmodified lines
230237 afterEach(() => {
231238 document.body.innerHTML = "";
232239 useComposerDraftStore.setState({
233− draftsByThreadId: {},
234− draftThreadsByThreadId: {},
235− projectDraftThreadIdByProjectId: {},
240+ draftsByThreadKey: {},
241+ draftThreadsByThreadKey: {},
242+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
236243 stickyModelSelectionByProvider: {},
237244 });
238245 });
@@ -369,10 +376,9 @@ describe("TraitsPicker (Claude)", () => { · 130 unmodified lines
369376 // ── Codex TraitsPicker tests ──────────────────────────────────────────
370377
371378 async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) {
372− const threadId = ThreadId.makeUnsafe("thread-codex-traits");
373379 const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex;
374− const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = {
375− [threadId]: {
380+ const draftsByThreadKey: Record<string, ComposerThreadDraftState> = {
381+ [CODEX_THREAD_KEY]: {
376382 prompt: "",
377383 images: [],
378384 nonPersistedImageIds: [],
@@ -392,10 +398,10 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt · 13 unmodified lines
392398 };
393399
394400 useComposerDraftStore.setState({
395− draftsByThreadId,
396− draftThreadsByThreadId: {},
397− projectDraftThreadIdByProjectId: {
398− [ProjectId.makeUnsafe("project-codex-traits")]: threadId,
401+ draftsByThreadKey,
402+ draftThreadsByThreadKey: {},
403+ logicalProjectDraftThreadKeyByLogicalProjectKey: {
404+ "environment-local:project-codex-traits": CODEX_THREAD_KEY,
399405 },
400406 });
401407 const host = document.createElement("div");
@@ -404,7 +410,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt · 2 unmodified lines
404410 <TraitsPicker
405411 provider="codex"
406412 models={TEST_PROVIDERS[0]!.models}
407− threadId={threadId}
413+ threadRef={CODEX_THREAD_REF}
408414 model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex}
409415 prompt=""
410416 modelOptions={props.options}
@@ -429,9 +435,9 @@ describe("TraitsPicker (Codex)", () => { · 18 unmodified lines
429435 document.body.innerHTML = "";
430436 localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY);
431437 useComposerDraftStore.setState({
432− draftsByThreadId: {},
433− draftThreadsByThreadId: {},
434− projectDraftThreadIdByProjectId: {},
438+ draftsByThreadKey: {},
439+ draftThreadsByThreadKey: {},
440+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
435441 stickyModelSelectionByProvider: {},
436442 });
437443 });
apps/web/src/components/chat/TraitsPicker.tsx+12−5
@@ -3,8 +3,8 @@ import { · 2 unmodified lines
33 type CodexModelOptions,
44 type ProviderKind,
55 type ProviderModelOptions,
6+ type ScopedThreadRef,
67 type ServerProviderModel,
7− type ThreadId,
88 } from "@t3tools/contracts";
99 import {
1010 applyClaudePromptEffortPrefix,
@@ -28,18 +28,19 @@ import { · 17 unmodified lines
2828 MenuSeparator as MenuDivider,
2929 MenuTrigger,
3030 } from "../ui/menu";
31−import { useComposerDraftStore } from "../../composerDraftStore";
31+import { useComposerDraftStore, DraftId } from "../../composerDraftStore";
3232 import { getProviderModelCapabilities } from "../../providerModels";
3333 import { cn } from "~/lib/utils";
3434
3535 type ProviderOptions = ProviderModelOptions[ProviderKind];
3636 type TraitsPersistence =
3737 | {
38− threadId: ThreadId;
38+ threadRef?: ScopedThreadRef;
39+ draftId?: DraftId;
3940 onModelOptionsChange?: never;
4041 }
4142 | {
42− threadId?: undefined;
43+ threadRef?: undefined;
4344 onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void;
4445 };
4546
@@ -167,7 +168,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 121 unmodified lines
167168 persistence.onModelOptionsChange(nextOptions);
168169 return;
169170 }
170− setProviderModelOptions(persistence.threadId, provider, nextOptions, { persistSticky: true });
171+ const threadTarget = persistence.threadRef ?? persistence.draftId;
172+ if (!threadTarget) {
173+ return;
174+ }
175+ setProviderModelOptions(threadTarget, provider, nextOptions, {
176+ persistSticky: true,
177+ });
171178 },
172179 [persistence, provider, setProviderModelOptions],
173180 );
apps/web/src/components/chat/composerProviderRegistry.test.tsx+33−1
@@ -1,6 +1,10 @@
11 import { describe, expect, it } from "vitest";
22 import type { ServerProviderModel } from "@t3tools/contracts";
3−import { getComposerProviderState } from "./composerProviderRegistry";
3+import {
4+ getComposerProviderState,
5+ renderProviderTraitsMenuContent,
6+ renderProviderTraitsPicker,
7+} from "./composerProviderRegistry";
48
59 const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [
610 {
@@ -417,3 +421,31 @@ describe("getComposerProviderState", () => { · 410 unmodified lines
417421 expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode");
418422 });
419423 });
424+
425+describe("provider traits render guards", () => {
426+ it("returns null for codex traits picker when no thread target is provided", () => {
427+ const content = renderProviderTraitsPicker({
428+ provider: "codex",
429+ model: "gpt-5.4",
430+ models: CODEX_MODELS,
431+ modelOptions: undefined,
432+ prompt: "",
433+ onPromptChange: () => {},
434+ });
435+
436+ expect(content).toBeNull();
437+ });
438+
439+ it("returns null for claude traits menu content when no thread target is provided", () => {
440+ const content = renderProviderTraitsMenuContent({
441+ provider: "claudeAgent",
442+ model: "claude-sonnet-4-6",
443+ models: CLAUDE_MODELS,
444+ modelOptions: undefined,
445+ prompt: "",
446+ onPromptChange: () => {},
447+ });
448+
449+ expect(content).toBeNull();
450+ });
451+});
apps/web/src/components/chat/composerProviderRegistry.tsx+93−53
@@ -1,11 +1,12 @@
11 import {
22 type ProviderKind,
33 type ProviderModelOptions,
4+ type ScopedThreadRef,
45 type ServerProviderModel,
5− type ThreadId,
66 } from "@t3tools/contracts";
77 import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model";
88 import type { ReactNode } from "react";
9+import type { DraftId } from "../../composerDraftStore";
910 import { getProviderModelCapabilities } from "../../providerModels";
1011 import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
1112 import {
@@ -33,7 +34,8 @@ export type ComposerProviderState = { · 21 unmodified lines
3334 type ProviderRegistryEntry = {
3435 getState: (input: ComposerProviderStateInput) => ComposerProviderState;
3536 renderTraitsMenuContent: (input: {
36− threadId: ThreadId;
37+ threadRef?: ScopedThreadRef;
38+ draftId?: DraftId;
3739 model: string;
3840 models: ReadonlyArray<ServerProviderModel>;
3941 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
@@ -41,7 +43,8 @@ type ProviderRegistryEntry = { · 1 unmodified line
4143 onPromptChange: (prompt: string) => void;
4244 }) => ReactNode;
4345 renderTraitsPicker: (input: {
44− threadId: ThreadId;
46+ threadRef?: ScopedThreadRef;
47+ draftId?: DraftId;
4548 model: string;
4649 models: ReadonlyArray<ServerProviderModel>;
4750 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
@@ -50,6 +53,13 @@ type ProviderRegistryEntry = { · 2 unmodified lines
5053 }) => ReactNode;
5154 };
5255
56+function hasComposerTraitsTarget(input: {
57+ threadRef: ScopedThreadRef | undefined;
58+ draftId: DraftId | undefined;
59+}): boolean {
60+ return input.threadRef !== undefined || input.draftId !== undefined;
61+}
62+
5363 function getProviderStateFromCapabilities(
5464 input: ComposerProviderStateInput,
5565 ): ComposerProviderState {
@@ -94,66 +104,92 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 38 unmodified lines
94104 codex: {
95105 getState: (input) => getProviderStateFromCapabilities(input),
96106 renderTraitsMenuContent: ({
97− threadId,
107+ threadRef,
108+ draftId,
109+ model,
110+ models,
111+ modelOptions,
112+ prompt,
113+ onPromptChange,
114+ }) =>
115+ !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
116+ <TraitsMenuContent
117+ provider="codex"
118+ models={models}
119+ {...(threadRef ? { threadRef } : {})}
120+ {...(draftId ? { draftId } : {})}
121+ model={model}
122+ modelOptions={modelOptions}
123+ prompt={prompt}
124+ onPromptChange={onPromptChange}
125+ />
126+ ),
127+ renderTraitsPicker: ({
128+ threadRef,
129+ draftId,
98130 model,
99131 models,
100132 modelOptions,
101133 prompt,
102134 onPromptChange,
103− }) => (
104− <TraitsMenuContent
105− provider="codex"
106− models={models}
107− threadId={threadId}
108− model={model}
109− modelOptions={modelOptions}
110− prompt={prompt}
111− onPromptChange={onPromptChange}
112− />
113− ),
114− renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => (
115− <TraitsPicker
116− provider="codex"
117− models={models}
118− threadId={threadId}
119− model={model}
120− modelOptions={modelOptions}
121− prompt={prompt}
122− onPromptChange={onPromptChange}
123− />
124− ),
135+ }) =>
136+ !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
137+ <TraitsPicker
138+ provider="codex"
139+ models={models}
140+ {...(threadRef ? { threadRef } : {})}
141+ {...(draftId ? { draftId } : {})}
142+ model={model}
143+ modelOptions={modelOptions}
144+ prompt={prompt}
145+ onPromptChange={onPromptChange}
146+ />
147+ ),
125148 },
126149 claudeAgent: {
127150 getState: (input) => getProviderStateFromCapabilities(input),
128151 renderTraitsMenuContent: ({
129− threadId,
152+ threadRef,
153+ draftId,
154+ model,
155+ models,
156+ modelOptions,
157+ prompt,
158+ onPromptChange,
159+ }) =>
160+ !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
161+ <TraitsMenuContent
162+ provider="claudeAgent"
163+ models={models}
164+ {...(threadRef ? { threadRef } : {})}
165+ {...(draftId ? { draftId } : {})}
166+ model={model}
167+ modelOptions={modelOptions}
168+ prompt={prompt}
169+ onPromptChange={onPromptChange}
170+ />
171+ ),
172+ renderTraitsPicker: ({
173+ threadRef,
174+ draftId,
130175 model,
131176 models,
132177 modelOptions,
133178 prompt,
134179 onPromptChange,
135− }) => (
136− <TraitsMenuContent
137− provider="claudeAgent"
138− models={models}
139− threadId={threadId}
140− model={model}
141− modelOptions={modelOptions}
142− prompt={prompt}
143− onPromptChange={onPromptChange}
144− />
145− ),
146− renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => (
147− <TraitsPicker
148− provider="claudeAgent"
149− models={models}
150− threadId={threadId}
151− model={model}
152− modelOptions={modelOptions}
153− prompt={prompt}
154− onPromptChange={onPromptChange}
155− />
156− ),
180+ }) =>
181+ !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
182+ <TraitsPicker
183+ provider="claudeAgent"
184+ models={models}
185+ {...(threadRef ? { threadRef } : {})}
186+ {...(draftId ? { draftId } : {})}
187+ model={model}
188+ modelOptions={modelOptions}
189+ prompt={prompt}
190+ onPromptChange={onPromptChange}
191+ />
192+ ),
157193 },
158194 };
159195
@@ -163,7 +199,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com · 3 unmodified lines
163199
164200 export function renderProviderTraitsMenuContent(input: {
165201 provider: ProviderKind;
166− threadId: ThreadId;
202+ threadRef?: ScopedThreadRef;
203+ draftId?: DraftId;
167204 model: string;
168205 models: ReadonlyArray<ServerProviderModel>;
169206 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
@@ -171,7 +208,8 @@ export function renderProviderTraitsMenuContent(input: { · 1 unmodified line
171208 onPromptChange: (prompt: string) => void;
172209 }): ReactNode {
173210 return composerProviderRegistry[input.provider].renderTraitsMenuContent({
174− threadId: input.threadId,
211+ ...(input.threadRef ? { threadRef: input.threadRef } : {}),
212+ ...(input.draftId ? { draftId: input.draftId } : {}),
175213 model: input.model,
176214 models: input.models,
177215 modelOptions: input.modelOptions,
@@ -182,7 +220,8 @@ export function renderProviderTraitsMenuContent(input: { · 4 unmodified lines
182220
183221 export function renderProviderTraitsPicker(input: {
184222 provider: ProviderKind;
185− threadId: ThreadId;
223+ threadRef?: ScopedThreadRef;
224+ draftId?: DraftId;
186225 model: string;
187226 models: ReadonlyArray<ServerProviderModel>;
188227 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
@@ -190,7 +229,8 @@ export function renderProviderTraitsPicker(input: { · 1 unmodified line
190229 onPromptChange: (prompt: string) => void;
191230 }): ReactNode {
192231 return composerProviderRegistry[input.provider].renderTraitsPicker({
193− threadId: input.threadId,
232+ ...(input.threadRef ? { threadRef: input.threadRef } : {}),
233+ ...(input.draftId ? { draftId: input.draftId } : {}),
194234 model: input.model,
195235 models: input.models,
196236 modelOptions: input.modelOptions,
apps/web/src/components/settings/SettingsPanels.browser.tsx+18−6
@@ -1,17 +1,29 @@
11 import "../../index.css";
22
3−import { DEFAULT_SERVER_SETTINGS, type NativeApi, type ServerConfig } from "@t3tools/contracts";
3+import {
4+ DEFAULT_SERVER_SETTINGS,
5+ EnvironmentId,
6+ type LocalApi,
7+ type ServerConfig,
8+} from "@t3tools/contracts";
49 import { page } from "vitest/browser";
510 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
611 import { render } from "vitest-browser-react";
712
8−import { __resetNativeApiForTests } from "../../nativeApi";
13+import { __resetLocalApiForTests } from "../../localApi";
914 import { AppAtomRegistryProvider } from "../../rpc/atomRegistry";
1015 import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
1116 import { GeneralSettingsPanel } from "./SettingsPanels";
1217
1318 function createBaseServerConfig(): ServerConfig {
1419 return {
20+ environment: {
21+ environmentId: EnvironmentId.makeUnsafe("environment-local"),
22+ label: "Local environment",
23+ platform: { os: "darwin" as const, arch: "arm64" as const },
24+ serverVersion: "0.0.0-test",
25+ capabilities: { repositoryIdentity: true },
26+ },
1527 cwd: "/repo/project",
1628 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
1729 keybindings: [],
@@ -32,14 +44,14 @@ function createBaseServerConfig(): ServerConfig { · 14 unmodified lines
3244 describe("GeneralSettingsPanel observability", () => {
3345 beforeEach(async () => {
3446 resetServerStateForTests();
35− await __resetNativeApiForTests();
47+ await __resetLocalApiForTests();
3648 localStorage.clear();
3749 document.body.innerHTML = "";
3850 });
3951
4052 afterEach(async () => {
4153 resetServerStateForTests();
42− await __resetNativeApiForTests();
54+ await __resetLocalApiForTests();
4355 document.body.innerHTML = "";
4456 });
4557
@@ -68,12 +80,12 @@ describe("GeneralSettingsPanel observability", () => { · 22 unmodified lines
6880 });
6981
7082 it("opens the logs folder in the preferred editor", async () => {
71− const openInEditor = vi.fn<NativeApi["shell"]["openInEditor"]>().mockResolvedValue(undefined);
83+ const openInEditor = vi.fn<LocalApi["shell"]["openInEditor"]>().mockResolvedValue(undefined);
7284 window.nativeApi = {
7385 shell: {
7486 openInEditor,
7587 },
76− } as unknown as NativeApi;
88+ } as unknown as LocalApi;
7789
7890 setServerConfigSnapshot(createBaseServerConfig());
7991
apps/web/src/components/settings/SettingsPanels.tsx+36−35
@@ -13,11 +13,12 @@ import { useQueryClient } from "@tanstack/react-query"; · 12 unmodified lines
1313 import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
1414 import {
1515 PROVIDER_DISPLAY_NAMES,
16+ type ScopedThreadRef,
1617 type ProviderKind,
1718 type ServerProvider,
1819 type ServerProviderModel,
19− ThreadId,
2020 } from "@t3tools/contracts";
21+import { scopeThreadRef } from "@t3tools/client-runtime";
2122 import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
2223 import { normalizeModelSlug } from "@t3tools/shared/model";
2324 import { Equal } from "effect";
@@ -45,8 +46,12 @@ import { · 21 unmodified lines
4546 getCustomModelOptionsByProvider,
4647 resolveAppModelSelectionState,
4748 } from "../../modelSelection";
48−import { ensureNativeApi, readNativeApi } from "../../nativeApi";
49−import { useStore } from "../../store";
49+import { ensureLocalApi, readLocalApi } from "../../localApi";
50+import {
51+ selectProjectsAcrossEnvironments,
52+ selectThreadsAcrossEnvironments,
53+ useStore,
54+} from "../../store";
5055 import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
5156 import { cn } from "../../lib/utils";
5257 import { Button } from "../ui/button";
@@ -496,8 +501,8 @@ export function useSettingsRestore(onRestored?: () => void) { · 443 unmodified lines
496501
497502 const restoreDefaults = useCallback(async () => {
498503 if (changedSettingLabels.length === 0) return;
499− const api = readNativeApi();
500− const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm(
504+ const api = readLocalApi();
505+ const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm(
501506 ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(
502507 "\n",
503508 ),
@@ -554,7 +559,7 @@ export function GeneralSettingsPanel() { · 50 unmodified lines
554559 if (refreshingRef.current) return;
555560 refreshingRef.current = true;
556561 setIsRefreshingProviders(true);
557− void ensureNativeApi()
562+ void ensureLocalApi()
558563 .server.refreshProviders()
559564 .catch((error: unknown) => {
560565 console.warn("Failed to refresh providers", error);
@@ -614,7 +619,7 @@ export function GeneralSettingsPanel() { · 53 unmodified lines
614619 return;
615620 }
616621
617− void ensureNativeApi()
622+ void ensureLocalApi()
618623 .shell.openInEditor(path, editor)
619624 .catch((error) => {
620625 setOpenPathErrorByTarget((existing) => ({
@@ -1479,20 +1484,10 @@ export function GeneralSettingsPanel() { · 858 unmodified lines
14791484 }
14801485
14811486 export function ArchivedThreadsPanel() {
1482− const projectIds = useStore((store) => store.projectIds);
1483− const projectById = useStore((store) => store.projectById);
1484− const threadIds = useStore((store) => store.threadIds);
1485− const threadShellById = useStore((store) => store.threadShellById);
1487+ const projects = useStore(selectProjectsAcrossEnvironments);
1488+ const threads = useStore(selectThreadsAcrossEnvironments);
14861489 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
14871490 const archivedGroups = useMemo(() => {
1488− const projects = projectIds.flatMap((projectId) => {
1489− const project = projectById[projectId];
1490− return project ? [project] : [];
1491− });
1492− const threads = threadIds.flatMap((threadId) => {
1493− const thread = threadShellById[threadId];
1494− return thread ? [thread] : [];
1495− });
14961491 return projects
14971492 .map((project) => ({
14981493 project,
@@ -1505,11 +1500,11 @@ export function ArchivedThreadsPanel() { · 6 unmodified lines
15051500 }),
15061501 }))
15071502 .filter((group) => group.threads.length > 0);
1508− }, [projectById, projectIds, threadIds, threadShellById]);
1503+ }, [projects, threads]);
15091504
15101505 const handleArchivedThreadContextMenu = useCallback(
1511− async (threadId: ThreadId, position: { x: number; y: number }) => {
1512− const api = readNativeApi();
1506+ async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
1507+ const api = readLocalApi();
15131508 if (!api) return;
15141509 const clicked = await api.contextMenu.show(
15151510 [
@@ -1521,7 +1516,7 @@ export function ArchivedThreadsPanel() { · 5 unmodified lines
15211516
15221517 if (clicked === "unarchive") {
15231518 try {
1524− await unarchiveThread(threadId);
1519+ await unarchiveThread(threadRef);
15251520 } catch (error) {
15261521 toastManager.add({
15271522 type: "error",
@@ -1533,7 +1528,7 @@ export function ArchivedThreadsPanel() { · 5 unmodified lines
15331528 }
15341529
15351530 if (clicked === "delete") {
1536− await confirmAndDeleteThread(threadId);
1531+ await confirmAndDeleteThread(threadRef);
15371532 }
15381533 },
15391534 [confirmAndDeleteThread, unarchiveThread],
@@ -1566,10 +1561,13 @@ export function ArchivedThreadsPanel() { · 26 unmodified lines
15661561 className="flex items-center justify-between gap-3 border-t border-border px-4 py-3 first:border-t-0 sm:px-5"
15671562 onContextMenu={(event) => {
15681563 event.preventDefault();
1569− void handleArchivedThreadContextMenu(thread.id, {
1570− x: event.clientX,
1571− y: event.clientY,
1572− });
1564+ void handleArchivedThreadContextMenu(
1565+ scopeThreadRef(thread.environmentId, thread.id),
1566+ {
1567+ x: event.clientX,
1568+ y: event.clientY,
1569+ },
1570+ );
15731571 }}
15741572 >
15751573 <div className="min-w-0 flex-1">
@@ -1586,13 +1584,16 @@ export function ArchivedThreadsPanel() { · 10 unmodified lines
15861584 size="sm"
15871585 className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5"
15881586 onClick={() =>
1589− void unarchiveThread(thread.id).catch((error) => {
1590− toastManager.add({
1591− type: "error",
1592− title: "Failed to unarchive thread",
1593− description: error instanceof Error ? error.message : "An error occurred.",
1594− });
1595− })
1587+ void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch(
1588+ (error) => {
1589+ toastManager.add({
1590+ type: "error",
1591+ title: "Failed to unarchive thread",
1592+ description:
1593+ error instanceof Error ? error.message : "An error occurred.",
1594+ });
1595+ },
1596+ )
15961597 }
15971598 >
15981599 <ArchiveX className="size-3.5" />
apps/web/src/composerDraftStore.test.ts+302−188
@@ -1,5 +1,12 @@
1+import {
2+ scopedProjectKey,
3+ scopedThreadKey,
4+ scopeProjectRef,
5+ scopeThreadRef,
6+} from "@t3tools/client-runtime";
17 import * as Schema from "effect/Schema";
28 import {
9+ EnvironmentId,
310 ProjectId,
411 ThreadId,
512 type ModelSelection,
@@ -9,10 +16,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; · 3 unmodified lines
916
1017 import {
1118 COMPOSER_DRAFT_STORAGE_KEY,
12− clearPromotedDraftThread,
13− clearPromotedDraftThreads,
19+ finalizePromotedDraftThreadByRef,
20+ markPromotedDraftThread,
21+ markPromotedDraftThreadByRef,
22+ markPromotedDraftThreads,
23+ markPromotedDraftThreadsByRef,
1424 type ComposerImageAttachment,
1525 useComposerDraftStore,
26+ DraftId,
1627 } from "./composerDraftStore";
1728 import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";
1829 import {
@@ -71,9 +82,9 @@ function makeTerminalContext(input: { · 52 unmodified lines
7182
7283 function resetComposerDraftStore() {
7384 useComposerDraftStore.setState({
74− draftsByThreadId: {},
75− draftThreadsByThreadId: {},
76− projectDraftThreadIdByProjectId: {},
85+ draftsByThreadKey: {},
86+ draftThreadsByThreadKey: {},
87+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
7788 stickyModelSelectionByProvider: {},
7889 stickyActiveProvider: null,
7990 });
@@ -95,8 +106,32 @@ function providerModelOptions(options: ProviderModelOptions): ProviderModelOptio · 15 unmodified lines
95106 return options;
96107 }
97108
109+const TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
110+const OTHER_TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-remote");
111+const LEGACY_TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("__legacy__");
112+
113+function threadKeyFor(
114+ threadId: ThreadId,
115+ environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID,
116+): string {
117+ if (environmentId === LEGACY_TEST_ENVIRONMENT_ID) {
118+ return threadId;
119+ }
120+ return scopedThreadKey(scopeThreadRef(environmentId, threadId));
121+}
122+
123+function draftFor(threadId: ThreadId, environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID) {
124+ const store = useComposerDraftStore.getState().draftsByThreadKey;
125+ return store[threadKeyFor(threadId, environmentId)] ?? store[threadId] ?? undefined;
126+}
127+
128+function draftByKey(key: string) {
129+ return useComposerDraftStore.getState().draftsByThreadKey[key] ?? undefined;
130+}
131+
98132 describe("composerDraftStore addImages", () => {
99133 const threadId = ThreadId.makeUnsafe("thread-dedupe");
134+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
100135 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
101136 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
102137
@@ -129,9 +164,9 @@ describe("composerDraftStore addImages", () => { · 26 unmodified lines
129164 lastModified: 12345,
130165 });
131166
132− useComposerDraftStore.getState().addImages(threadId, [first, duplicate]);
167+ useComposerDraftStore.getState().addImages(threadRef, [first, duplicate]);
133168
134− const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
169+ const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
135170 expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]);
136171 expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate");
137172 });
@@ -154,10 +189,10 @@ describe("composerDraftStore addImages", () => { · 16 unmodified lines
154189 lastModified: 999,
155190 });
156191
157− useComposerDraftStore.getState().addImage(threadId, first);
158− useComposerDraftStore.getState().addImage(threadId, duplicateLater);
192+ useComposerDraftStore.getState().addImage(threadRef, first);
193+ useComposerDraftStore.getState().addImage(threadRef, duplicateLater);
159194
160− const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
195+ const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
161196 expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]);
162197 expect(revokeSpy).toHaveBeenCalledWith("blob:b");
163198 });
@@ -172,9 +207,9 @@ describe("composerDraftStore addImages", () => { · 8 unmodified lines
172207 previewUrl: "blob:shared",
173208 });
174209
175− useComposerDraftStore.getState().addImages(threadId, [first, duplicateSameUrl]);
210+ useComposerDraftStore.getState().addImages(threadRef, [first, duplicateSameUrl]);
176211
177− const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
212+ const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
178213 expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]);
179214 expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared");
180215 });
@@ -182,6 +217,7 @@ describe("composerDraftStore addImages", () => { · 1 unmodified line
182217
183218 describe("composerDraftStore clearComposerContent", () => {
184219 const threadId = ThreadId.makeUnsafe("thread-clear");
220+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
185221 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
186222 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
187223
@@ -201,11 +237,11 @@ describe("composerDraftStore clearComposerContent", () => { · 13 unmodified lines
201237 id: "img-optimistic",
202238 previewUrl: "blob:optimistic",
203239 });
204− useComposerDraftStore.getState().addImage(threadId, first);
240+ useComposerDraftStore.getState().addImage(threadRef, first);
205241
206− useComposerDraftStore.getState().clearComposerContent(threadId);
242+ useComposerDraftStore.getState().clearComposerContent(threadRef);
207243
208− const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
244+ const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
209245 expect(draft).toBeUndefined();
210246 expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic");
211247 });
@@ -213,13 +249,14 @@ describe("composerDraftStore clearComposerContent", () => { · 1 unmodified line
213249
214250 describe("composerDraftStore syncPersistedAttachments", () => {
215251 const threadId = ThreadId.makeUnsafe("thread-sync-persisted");
252+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
216253
217254 beforeEach(() => {
218255 removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY);
219256 useComposerDraftStore.setState({
220− draftsByThreadId: {},
221− draftThreadsByThreadId: {},
222− projectDraftThreadIdByProjectId: {},
257+ draftsByThreadKey: {},
258+ draftThreadsByThreadKey: {},
259+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
223260 stickyModelSelectionByProvider: {},
224261 stickyActiveProvider: null,
225262 });
@@ -234,7 +271,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { · 8 unmodified lines
234271 id: "img-persisted",
235272 previewUrl: "blob:persisted",
236273 });
237− useComposerDraftStore.getState().addImage(threadId, image);
274+ useComposerDraftStore.getState().addImage(threadRef, image);
238275 setLocalStorageItem(
239276 COMPOSER_DRAFT_STORAGE_KEY,
240277 {
@@ -250,7 +287,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { · 9 unmodified lines
250287 Schema.Unknown,
251288 );
252289
253− useComposerDraftStore.getState().syncPersistedAttachments(threadId, [
290+ useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [
254291 {
255292 id: image.id,
256293 name: image.name,
@@ -261,23 +298,20 @@ describe("composerDraftStore syncPersistedAttachments", () => { · 4 unmodified lines
261298 ]);
262299 await Promise.resolve();
263300
264− expect(
265− useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments,
266− ).toEqual([]);
267− expect(
268− useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedImageIds,
269− ).toEqual([image.id]);
301+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual([]);
302+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.nonPersistedImageIds).toEqual([image.id]);
270303 });
271304 });
272305
273306 describe("composerDraftStore terminal contexts", () => {
274307 const threadId = ThreadId.makeUnsafe("thread-dedupe");
308+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
275309
276310 beforeEach(() => {
277311 useComposerDraftStore.setState({
278− draftsByThreadId: {},
279− draftThreadsByThreadId: {},
280− projectDraftThreadIdByProjectId: {},
312+ draftsByThreadKey: {},
313+ draftThreadsByThreadKey: {},
314+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
281315 stickyModelSelectionByProvider: {},
282316 stickyActiveProvider: null,
283317 });
@@ -287,20 +321,20 @@ describe("composerDraftStore terminal contexts", () => { · 3 unmodified lines
287321 const first = makeTerminalContext({ id: "ctx-1" });
288322 const duplicate = makeTerminalContext({ id: "ctx-2" });
289323
290− useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]);
324+ useComposerDraftStore.getState().addTerminalContexts(threadRef, [first, duplicate]);
291325
292− const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
326+ const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
293327 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]);
294328 });
295329
296330 it("clears terminal contexts when clearing composer content", () => {
297331 useComposerDraftStore
298332 .getState()
299− .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-1" }));
333+ .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-1" }));
300334
301− useComposerDraftStore.getState().clearComposerContent(threadId);
335+ useComposerDraftStore.getState().clearComposerContent(threadRef);
302336
303− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
337+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
304338 });
305339
306340 it("inserts terminal contexts at the requested inline prompt position", () => {
@@ -311,7 +345,7 @@ describe("composerDraftStore terminal contexts", () => { · 4 unmodified lines
311345 useComposerDraftStore
312346 .getState()
313347 .insertTerminalContext(
314− threadId,
348+ threadRef,
315349 firstInsertion.prompt,
316350 makeTerminalContext({ id: "ctx-1" }),
317351 firstInsertion.contextIndex,
@@ -319,7 +353,7 @@ describe("composerDraftStore terminal contexts", () => { · 1 unmodified line
319353 ).toBe(true);
320354 expect(
321355 useComposerDraftStore.getState().insertTerminalContext(
322− threadId,
356+ threadRef,
323357 secondInsertion.prompt,
324358 makeTerminalContext({
325359 id: "ctx-2",
@@ -331,7 +365,7 @@ describe("composerDraftStore terminal contexts", () => { · 5 unmodified lines
331365 ),
332366 ).toBe(true);
333367
334− const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
368+ const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
335369 expect(draft?.prompt).toBe(
336370 `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`,
337371 );
@@ -341,7 +375,7 @@ describe("composerDraftStore terminal contexts", () => { · 3 unmodified lines
341375 it("omits terminal context text from persisted drafts", () => {
342376 useComposerDraftStore
343377 .getState()
344− .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-persist" }));
378+ .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" }));
345379
346380 const persistApi = useComposerDraftStore.persist as unknown as {
347381 getOptions: () => {
@@ -349,11 +383,12 @@ describe("composerDraftStore terminal contexts", () => { · 1 unmodified line
349383 };
350384 };
351385 const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {
352− draftsByThreadId?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>;
386+ draftsByThreadKey?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>;
353387 };
354388
355389 expect(
356− persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0],
390+ persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]
391+ ?.terminalContexts?.[0],
357392 "Expected terminal context metadata to be persisted.",
358393 ).toMatchObject({
359394 id: "ctx-persist",
@@ -363,7 +398,8 @@ describe("composerDraftStore terminal contexts", () => { · 3 unmodified lines
363398 lineEnd: 5,
364399 });
365400 expect(
366− persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0]?.text,
401+ persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]
402+ ?.terminalContexts?.[0]?.text,
367403 ).toBeUndefined();
368404 });
369405
@@ -396,12 +432,12 @@ describe("composerDraftStore terminal contexts", () => { · 26 unmodified lines
396432 },
397433 },
398434 draftThreadsByThreadId: {},
399− projectDraftThreadIdByProjectId: {},
435+ projectDraftThreadIdByProjectKey: {},
400436 },
401437 useComposerDraftStore.getInitialState(),
402438 );
403439
404− expect(mergedState.draftsByThreadId[threadId]?.terminalContexts).toMatchObject([
440+ expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.terminalContexts).toMatchObject([
405441 {
406442 id: "ctx-rehydrated",
407443 terminalId: "default",
@@ -434,22 +470,30 @@ describe("composerDraftStore terminal contexts", () => { · 26 unmodified lines
434470 },
435471 },
436472 draftThreadsByThreadId: "not-an-object",
437− projectDraftThreadIdByProjectId: "not-an-object",
473+ projectDraftThreadIdByProjectKey: "not-an-object",
438474 },
439475 useComposerDraftStore.getInitialState(),
440476 );
441477
442− expect(mergedState.draftsByThreadId[threadId]).toBeUndefined();
443− expect(mergedState.draftThreadsByThreadId).toEqual({});
444− expect(mergedState.projectDraftThreadIdByProjectId).toEqual({});
478+ expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined();
479+ expect(mergedState.draftThreadsByThreadKey).toEqual({});
480+ expect(mergedState.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual({});
445481 });
446482 });
447483
448484 describe("composerDraftStore project draft thread mapping", () => {
449485 const projectId = ProjectId.makeUnsafe("project-a");
450486 const otherProjectId = ProjectId.makeUnsafe("project-b");
487+ const projectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, projectId);
488+ const otherProjectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, otherProjectId);
489+ const remoteProjectRef = scopeProjectRef(OTHER_TEST_ENVIRONMENT_ID, projectId);
451490 const threadId = ThreadId.makeUnsafe("thread-a");
452491 const otherThreadId = ThreadId.makeUnsafe("thread-b");
492+ const draftId = DraftId.makeUnsafe("draft-a");
493+ const otherDraftId = DraftId.makeUnsafe("draft-b");
494+ const sharedDraftId = DraftId.makeUnsafe("draft-shared");
495+ const localDraftId = DraftId.makeUnsafe("draft-local");
496+ const remoteDraftId = DraftId.makeUnsafe("draft-remote");
453497
454498 beforeEach(() => {
455499 resetComposerDraftStore();
@@ -457,17 +501,20 @@ describe("composerDraftStore project draft thread mapping", () => { · 1 unmodified line
457501
458502 it("stores and reads project draft thread ids via actions", () => {
459503 const store = useComposerDraftStore.getState();
460− expect(store.getDraftThreadByProjectId(projectId)).toBeNull();
461− expect(store.getDraftThread(threadId)).toBeNull();
504+ expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
505+ expect(store.getDraftThread(draftId)).toBeNull();
462506
463− store.setProjectDraftThreadId(projectId, threadId, {
507+ store.setProjectDraftThreadId(projectRef, draftId, {
508+ threadId,
464509 branch: "feature/test",
465510 worktreePath: "/tmp/worktree-test",
466511 createdAt: "2026-01-01T00:00:00.000Z",
467512 });
468− expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({
513+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toMatchObject({
469514 threadId,
515+ environmentId: TEST_ENVIRONMENT_ID,
470516 projectId,
517+ logicalProjectKey: scopedProjectKey(projectRef),
471518 branch: "feature/test",
472519 worktreePath: "/tmp/worktree-test",
473520 envMode: "worktree",
@@ -475,8 +522,10 @@ describe("composerDraftStore project draft thread mapping", () => { · 1 unmodified line
475522 interactionMode: "default",
476523 createdAt: "2026-01-01T00:00:00.000Z",
477524 });
478− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({
525+ expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
526+ environmentId: TEST_ENVIRONMENT_ID,
479527 projectId,
528+ logicalProjectKey: scopedProjectKey(projectRef),
480529 branch: "feature/test",
481530 worktreePath: "/tmp/worktree-test",
482531 envMode: "worktree",
@@ -488,134 +537,210 @@ describe("composerDraftStore project draft thread mapping", () => { · 5 unmodified lines
488537
489538 it("clears only matching project draft mapping entries", () => {
490539 const store = useComposerDraftStore.getState();
491− store.setProjectDraftThreadId(projectId, threadId);
492− store.setPrompt(threadId, "hello");
540+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
541+ store.setPrompt(draftId, "hello");
493542
494− store.clearProjectDraftThreadById(projectId, otherThreadId);
495− expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
543+ store.clearProjectDraftThreadById(projectRef, otherDraftId);
544+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
496545 threadId,
497546 );
498547
499− store.clearProjectDraftThreadById(projectId, threadId);
500− expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
501− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
502− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
548+ store.clearProjectDraftThreadById(projectRef, draftId);
549+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
550+ expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
551+ expect(draftByKey(draftId)).toBeUndefined();
503552 });
504553
505554 it("clears project draft mapping by project id", () => {
506555 const store = useComposerDraftStore.getState();
507− store.setProjectDraftThreadId(projectId, threadId);
508− store.setPrompt(threadId, "hello");
509− store.clearProjectDraftThreadId(projectId);
510− expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
511− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
512− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
556+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
557+ store.setPrompt(draftId, "hello");
558+ store.clearProjectDraftThreadId(projectRef);
559+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
560+ expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
561+ expect(draftByKey(draftId)).toBeUndefined();
513562 });
514563
515564 it("clears orphaned composer drafts when remapping a project to a new draft thread", () => {
516565 const store = useComposerDraftStore.getState();
517− store.setProjectDraftThreadId(projectId, threadId);
518− store.setPrompt(threadId, "orphan me");
566+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
567+ store.setPrompt(draftId, "orphan me");
519568
520− store.setProjectDraftThreadId(projectId, otherThreadId);
569+ store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId });
521570
522− expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
571+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
523572 otherThreadId,
524573 );
525− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
526− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
574+ expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
575+ expect(draftByKey(draftId)).toBeUndefined();
527576 });
528577
529578 it("keeps composer drafts when the thread is still mapped by another project", () => {
530579 const store = useComposerDraftStore.getState();
531− store.setProjectDraftThreadId(projectId, threadId);
532− store.setProjectDraftThreadId(otherProjectId, threadId);
533− store.setPrompt(threadId, "keep me");
580+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
581+ store.setProjectDraftThreadId(otherProjectRef, sharedDraftId, { threadId });
582+ store.setPrompt(sharedDraftId, "keep me");
534583
535− store.clearProjectDraftThreadId(projectId);
584+ store.clearProjectDraftThreadId(projectRef);
536585
537− expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
586+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
538587 expect(
539− useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId,
588+ useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId,
540589 ).toBe(threadId);
541− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");
590+ expect(draftByKey(sharedDraftId)?.prompt).toBe("keep me");
542591 });
543592
544593 it("clears draft registration independently", () => {
545594 const store = useComposerDraftStore.getState();
546− store.setProjectDraftThreadId(projectId, threadId);
547− store.setPrompt(threadId, "remove me");
548− store.clearDraftThread(threadId);
549− expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
550− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
551− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
595+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
596+ store.setPrompt(draftId, "remove me");
597+ store.clearDraftThread(draftId);
598+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
599+ expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
600+ expect(draftByKey(draftId)).toBeUndefined();
601+ });
602+
603+ it("marks a promoted draft by thread id without deleting composer state", () => {
604+ const store = useComposerDraftStore.getState();
605+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
606+ store.setPrompt(draftId, "promote me");
607+
608+ markPromotedDraftThread(threadId);
609+
610+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
611+ expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual(
612+ scopeThreadRef(TEST_ENVIRONMENT_ID, threadId),
613+ );
614+ expect(draftByKey(draftId)?.prompt).toBe("promote me");
552615 });
553616
554− it("clears a promoted draft by thread id", () => {
617+ it("reads local draft composer state through a scoped thread ref", () => {
555618 const store = useComposerDraftStore.getState();
556− store.setProjectDraftThreadId(projectId, threadId);
557− store.setPrompt(threadId, "promote me");
619+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
558620
559− clearPromotedDraftThread(threadId);
621+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
622+ store.setPrompt(draftId, "scoped access");
560623
561− expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
562− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
563− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
624+ expect(store.getComposerDraft(draftId)?.prompt).toBe("scoped access");
625+ expect(store.getComposerDraft(threadRef)?.prompt).toBe("scoped access");
564626 });
565627
566628 it("does not clear composer drafts for existing server threads during promotion cleanup", () => {
567629 const store = useComposerDraftStore.getState();
568− store.setPrompt(threadId, "keep me");
630+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
631+ store.setPrompt(threadRef, "keep me");
569632
570− clearPromotedDraftThread(threadId);
633+ markPromotedDraftThread(threadId);
571634
572− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
573− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");
635+ expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull();
636+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me");
574637 });
575638
576− it("clears promoted drafts from an iterable of server thread ids", () => {
639+ it("marks promoted drafts from an iterable of server thread ids", () => {
577640 const store = useComposerDraftStore.getState();
578− store.setProjectDraftThreadId(projectId, threadId);
579− store.setPrompt(threadId, "promote me");
580− store.setProjectDraftThreadId(otherProjectId, otherThreadId);
581− store.setPrompt(otherThreadId, "keep me");
641+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
642+ store.setPrompt(draftId, "promote me");
643+ store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId });
644+ store.setPrompt(otherDraftId, "keep me");
582645
583− clearPromotedDraftThreads([threadId]);
646+ markPromotedDraftThreads([threadId]);
584647
585− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
586− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
648+ expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual(
649+ scopeThreadRef(TEST_ENVIRONMENT_ID, threadId),
650+ );
651+ expect(draftByKey(draftId)?.prompt).toBe("promote me");
587652 expect(
588− useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId,
653+ useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId,
589654 ).toBe(otherThreadId);
590− expect(useComposerDraftStore.getState().draftsByThreadId[otherThreadId]?.prompt).toBe(
591− "keep me",
655+ expect(draftByKey(otherDraftId)?.prompt).toBe("keep me");
656+ });
657+
658+ it("marks every matching scoped draft when multiple environments share a thread id", () => {
659+ const store = useComposerDraftStore.getState();
660+ const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
661+ const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId);
662+
663+ store.setProjectDraftThreadId(projectRef, localDraftId, { threadId });
664+ store.setPrompt(localDraftId, "local draft");
665+ store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { threadId });
666+ store.setPrompt(remoteDraftId, "remote draft");
667+
668+ markPromotedDraftThread(threadId);
669+
670+ expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
671+ expect(store.getDraftThreadByProjectRef(remoteProjectRef)).toBeNull();
672+ expect(store.getDraftThreadByRef(localThreadRef)?.promotedTo).toEqual(localThreadRef);
673+ expect(store.getDraftThreadByRef(remoteThreadRef)?.promotedTo).toEqual(remoteThreadRef);
674+ expect(draftByKey(localDraftId)?.prompt).toBe("local draft");
675+ expect(draftByKey(remoteDraftId)?.prompt).toBe("remote draft");
676+ });
677+
678+ it("only marks promoted drafts for the matching environment ref", () => {
679+ const store = useComposerDraftStore.getState();
680+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
681+ store.setPrompt(draftId, "promote me");
682+
683+ markPromotedDraftThreadByRef(scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId));
684+
685+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
686+ threadId,
687+ );
688+ expect(draftByKey(draftId)?.prompt).toBe("promote me");
689+ });
690+
691+ it("only marks iterable promotion cleanup entries for the matching environment refs", () => {
692+ const store = useComposerDraftStore.getState();
693+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
694+ store.setPrompt(draftId, "promote me");
695+
696+ markPromotedDraftThreadsByRef([scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId)]);
697+
698+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
699+ threadId,
592700 );
701+ expect(draftByKey(draftId)?.prompt).toBe("promote me");
593702 });
594703
595704 it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => {
596705 const store = useComposerDraftStore.getState();
597− store.setPrompt(threadId, "keep me");
706+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
707+ store.setPrompt(threadRef, "keep me");
598708
599− clearPromotedDraftThreads([threadId]);
709+ markPromotedDraftThreads([threadId]);
600710
601− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
602− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");
711+ expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull();
712+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me");
713+ });
714+
715+ it("finalizes a promoted draft after the canonical thread route is active", () => {
716+ const store = useComposerDraftStore.getState();
717+ store.setProjectDraftThreadId(projectRef, draftId, { threadId });
718+ store.setPrompt(draftId, "promote me");
719+ markPromotedDraftThread(threadId);
720+
721+ finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
722+
723+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
724+ expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
725+ expect(draftByKey(draftId)).toBeUndefined();
603726 });
604727
605728 it("updates branch context on an existing draft thread", () => {
606729 const store = useComposerDraftStore.getState();
607− store.setProjectDraftThreadId(projectId, threadId, {
730+ store.setProjectDraftThreadId(projectRef, draftId, {
731+ threadId,
608732 branch: "main",
609733 worktreePath: null,
610734 });
611− store.setDraftThreadContext(threadId, {
735+ store.setDraftThreadContext(draftId, {
612736 branch: "feature/next",
613737 worktreePath: "/tmp/feature-next",
614738 });
615− expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
739+ expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
616740 threadId,
617741 );
618− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({
742+ expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
743+ environmentId: TEST_ENVIRONMENT_ID,
619744 projectId,
620745 branch: "feature/next",
621746 worktreePath: "/tmp/feature-next",
@@ -625,7 +750,8 @@ describe("composerDraftStore project draft thread mapping", () => { · 3 unmodified lines
625750
626751 it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => {
627752 const store = useComposerDraftStore.getState();
628− store.setProjectDraftThreadId(projectId, threadId, {
753+ store.setProjectDraftThreadId(projectRef, draftId, {
754+ threadId,
629755 branch: "main",
630756 worktreePath: "/tmp/main-worktree",
631757 });
@@ -636,9 +762,10 @@ describe("composerDraftStore project draft thread mapping", () => { · 4 unmodified lines
636762 branch?: string | null;
637763 worktreePath?: string | null;
638764 };
639− store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions);
765+ store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions);
640766
641− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({
767+ expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
768+ environmentId: TEST_ENVIRONMENT_ID,
642769 projectId,
643770 branch: "main",
644771 worktreePath: "/tmp/main-worktree",
@@ -648,7 +775,8 @@ describe("composerDraftStore project draft thread mapping", () => { · 3 unmodified lines
648775
649776 it("preserves worktree env mode without a worktree path", () => {
650777 const store = useComposerDraftStore.getState();
651− store.setProjectDraftThreadId(projectId, threadId, {
778+ store.setProjectDraftThreadId(projectRef, draftId, {
779+ threadId,
652780 branch: "feature/base",
653781 worktreePath: null,
654782 envMode: "worktree",
@@ -662,9 +790,10 @@ describe("composerDraftStore project draft thread mapping", () => { · 7 unmodified lines
662790 worktreePath?: string | null;
663791 envMode?: "local" | "worktree";
664792 };
665− store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions);
793+ store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions);
666794
667− expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({
795+ expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
796+ environmentId: TEST_ENVIRONMENT_ID,
668797 projectId,
669798 branch: "feature/base",
670799 worktreePath: null,
@@ -675,6 +804,7 @@ describe("composerDraftStore project draft thread mapping", () => { · 4 unmodified lines
675804
676805 describe("composerDraftStore modelSelection", () => {
677806 const threadId = ThreadId.makeUnsafe("thread-model-options");
807+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
678808
679809 beforeEach(() => {
680810 resetComposerDraftStore();
@@ -683,16 +813,14 @@ describe("composerDraftStore modelSelection", () => { · 2 unmodified lines
683813 it("stores a model selection in the draft", () => {
684814 const store = useComposerDraftStore.getState();
685815 store.setModelSelection(
686− threadId,
816+ threadRef,
687817 modelSelection("codex", "gpt-5.3-codex", {
688818 reasoningEffort: "xhigh",
689819 fastMode: true,
690820 }),
691821 );
692822
693− expect(
694− useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
695− ).toEqual(
823+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
696824 modelSelection("codex", "gpt-5.3-codex", {
697825 reasoningEffort: "xhigh",
698826 fastMode: true,
@@ -702,18 +830,18 @@ describe("composerDraftStore modelSelection", () => { · 3 unmodified lines
702830
703831 it("keeps default-only model selections on the draft", () => {
704832 const store = useComposerDraftStore.getState();
705− store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4"));
833+ store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4"));
706834
707− expect(
708− useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
709− ).toEqual(modelSelection("codex", "gpt-5.4"));
835+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
836+ modelSelection("codex", "gpt-5.4"),
837+ );
710838 });
711839
712840 it("replaces only the targeted provider options on the current model selection", () => {
713841 const store = useComposerDraftStore.getState();
714842
715843 store.setModelSelection(
716− threadId,
844+ threadRef,
717845 modelSelection("claudeAgent", "claude-opus-4-6", {
718846 effort: "max",
719847 fastMode: true,
@@ -727,7 +855,7 @@ describe("composerDraftStore modelSelection", () => { · 7 unmodified lines
727855 );
728856
729857 store.setProviderModelOptions(
730− threadId,
858+ threadRef,
731859 "claudeAgent",
732860 {
733861 thinking: false,
@@ -735,10 +863,7 @@ describe("composerDraftStore modelSelection", () => { · 1 unmodified line
735863 { persistSticky: true },
736864 );
737865
738− expect(
739− useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
740− .claudeAgent,
741− ).toEqual(
866+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
742867 modelSelection("claudeAgent", "claude-opus-4-6", {
743868 thinking: false,
744869 }),
@@ -754,20 +879,17 @@ describe("composerDraftStore modelSelection", () => { · 9 unmodified lines
754879 const store = useComposerDraftStore.getState();
755880
756881 store.setModelSelection(
757− threadId,
882+ threadRef,
758883 modelSelection("claudeAgent", "claude-opus-4-6", {
759884 effort: "max",
760885 }),
761886 );
762887
763− store.setProviderModelOptions(threadId, "claudeAgent", {
888+ store.setProviderModelOptions(threadRef, "claudeAgent", {
764889 thinking: true,
765890 });
766891
767− expect(
768− useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
769− .claudeAgent,
770− ).toEqual(
892+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
771893 modelSelection("claudeAgent", "claude-opus-4-6", {
772894 thinking: true,
773895 }),
@@ -778,16 +900,14 @@ describe("composerDraftStore modelSelection", () => { · 4 unmodified lines
778900 it("keeps explicit off/default codex overrides on the selection", () => {
779901 const store = useComposerDraftStore.getState();
780902
781− store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true }));
903+ store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true }));
782904
783− store.setProviderModelOptions(threadId, "codex", {
905+ store.setProviderModelOptions(threadRef, "codex", {
784906 reasoningEffort: "high",
785907 fastMode: false,
786908 });
787909
788− expect(
789− useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
790− ).toEqual(
910+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
791911 modelSelection("codex", "gpt-5.4", {
792912 reasoningEffort: "high",
793913 fastMode: false,
@@ -802,18 +922,15 @@ describe("composerDraftStore modelSelection", () => { · 8 unmodified lines
802922 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
803923 );
804924 store.setModelSelection(
805− threadId,
925+ threadRef,
806926 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
807927 );
808928
809− store.setProviderModelOptions(threadId, "claudeAgent", {
929+ store.setProviderModelOptions(threadRef, "claudeAgent", {
810930 thinking: false,
811931 });
812932
813− expect(
814− useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
815− .claudeAgent,
816− ).toEqual(
933+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
817934 modelSelection("claudeAgent", "claude-opus-4-6", {
818935 thinking: false,
819936 }),
@@ -828,7 +945,7 @@ describe("composerDraftStore modelSelection", () => { · 8 unmodified lines
828945
829946 // Set options for both providers
830947 store.setModelOptions(
831− threadId,
948+ threadRef,
832949 providerModelOptions({
833950 codex: { fastMode: true },
834951 claudeAgent: { effort: "max" },
@@ -836,9 +953,9 @@ describe("composerDraftStore modelSelection", () => { · 1 unmodified line
836953 );
837954
838955 // Now set options for only codex — claudeAgent should be untouched
839− store.setModelOptions(threadId, providerModelOptions({ codex: { reasoningEffort: "xhigh" } }));
956+ store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } }));
840957
841− const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
958+ const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
842959 expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" });
843960 expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" });
844961 });
@@ -847,16 +964,16 @@ describe("composerDraftStore modelSelection", () => { · 2 unmodified lines
847964 const store = useComposerDraftStore.getState();
848965
849966 store.setModelOptions(
850− threadId,
967+ threadRef,
851968 providerModelOptions({
852969 codex: { fastMode: true },
853970 claudeAgent: { effort: "max" },
854971 }),
855972 );
856973
857− store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6"));
974+ store.setModelSelection(threadRef, modelSelection("claudeAgent", "claude-opus-4-6"));
858975
859− const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
976+ const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
860977 expect(draft?.modelSelectionByProvider.claudeAgent).toEqual(
861978 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
862979 );
@@ -867,10 +984,10 @@ describe("composerDraftStore modelSelection", () => { · 4 unmodified lines
867984 it("creates the first sticky snapshot from provider option changes", () => {
868985 const store = useComposerDraftStore.getState();
869986
870− store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4"));
987+ store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4"));
871988
872989 store.setProviderModelOptions(
873− threadId,
990+ threadRef,
874991 "codex",
875992 {
876993 fastMode: true,
@@ -892,12 +1009,12 @@ describe("composerDraftStore modelSelection", () => { · 15 unmodified lines
8921009 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
8931010 );
8941011 store.setModelSelection(
895− threadId,
1012+ threadRef,
8961013 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
8971014 );
8981015
8991016 store.setProviderModelOptions(
900− threadId,
1017+ threadRef,
9011018 "claudeAgent",
9021019 {
9031020 thinking: false,
@@ -905,10 +1022,7 @@ describe("composerDraftStore modelSelection", () => { · 1 unmodified line
9051022 { persistSticky: false },
9061023 );
9071024
908− expect(
909− useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
910− .claudeAgent,
911− ).toEqual(
1025+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
9121026 modelSelection("claudeAgent", "claude-opus-4-6", {
9131027 thinking: false,
9141028 }),
@@ -921,6 +1035,7 @@ describe("composerDraftStore modelSelection", () => { · 6 unmodified lines
9211035
9221036 describe("composerDraftStore setModelSelection", () => {
9231037 const threadId = ThreadId.makeUnsafe("thread-model");
1038+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
9241039
9251040 beforeEach(() => {
9261041 resetComposerDraftStore();
@@ -929,11 +1044,11 @@ describe("composerDraftStore setModelSelection", () => { · 2 unmodified lines
9291044 it("keeps explicit model overrides instead of coercing to null", () => {
9301045 const store = useComposerDraftStore.getState();
9311046
932− store.setModelSelection(threadId, modelSelection("codex", "gpt-5.3-codex"));
1047+ store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.3-codex"));
9331048
934− expect(
935− useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
936− ).toEqual(modelSelection("codex", "gpt-5.3-codex"));
1049+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
1050+ modelSelection("codex", "gpt-5.3-codex"),
1051+ );
9371052 });
9381053 });
9391054
@@ -975,11 +1090,12 @@ describe("composerDraftStore sticky composer settings", () => { · 35 unmodified lines
9751090 it("applies sticky activeProvider to new drafts", () => {
9761091 const store = useComposerDraftStore.getState();
9771092 const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider");
1093+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
9781094
9791095 store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6"));
980− store.applyStickyState(threadId);
1096+ store.applyStickyState(threadRef);
9811097
982− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
1098+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({
9831099 modelSelectionByProvider: {
9841100 claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"),
9851101 },
@@ -990,6 +1106,7 @@ describe("composerDraftStore sticky composer settings", () => { · 4 unmodified lines
9901106
9911107 describe("composerDraftStore provider-scoped option updates", () => {
9921108 const threadId = ThreadId.makeUnsafe("thread-provider");
1109+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
9931110
9941111 beforeEach(() => {
9951112 resetComposerDraftStore();
@@ -998,13 +1115,13 @@ describe("composerDraftStore provider-scoped option updates", () => { · 2 unmodified lines
9981115 it("retains off-provider option memory without changing the active selection", () => {
9991116 const store = useComposerDraftStore.getState();
10001117 store.setModelSelection(
1001− threadId,
1118+ threadRef,
10021119 modelSelection("codex", "gpt-5.3-codex", {
10031120 reasoningEffort: "medium",
10041121 }),
10051122 );
1006− store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" });
1007− const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
1123+ store.setProviderModelOptions(threadRef, "claudeAgent", { effort: "max" });
1124+ const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
10081125 expect(draft?.modelSelectionByProvider.codex).toEqual(
10091126 modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }),
10101127 );
@@ -1015,6 +1132,7 @@ describe("composerDraftStore provider-scoped option updates", () => { · 4 unmodified lines
10151132
10161133 describe("composerDraftStore runtime and interaction settings", () => {
10171134 const threadId = ThreadId.makeUnsafe("thread-settings");
1135+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
10181136
10191137 beforeEach(() => {
10201138 resetComposerDraftStore();
@@ -1023,32 +1141,28 @@ describe("composerDraftStore runtime and interaction settings", () => { · 2 unmodified lines
10231141 it("stores runtime mode overrides in the composer draft", () => {
10241142 const store = useComposerDraftStore.getState();
10251143
1026− store.setRuntimeMode(threadId, "approval-required");
1144+ store.setRuntimeMode(threadRef, "approval-required");
10271145
1028− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.runtimeMode).toBe(
1029− "approval-required",
1030− );
1146+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.runtimeMode).toBe("approval-required");
10311147 });
10321148
10331149 it("stores interaction mode overrides in the composer draft", () => {
10341150 const store = useComposerDraftStore.getState();
10351151
1036− store.setInteractionMode(threadId, "plan");
1152+ store.setInteractionMode(threadRef, "plan");
10371153
1038− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.interactionMode).toBe(
1039− "plan",
1040− );
1154+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.interactionMode).toBe("plan");
10411155 });
10421156
10431157 it("removes empty settings-only drafts when overrides are cleared", () => {
10441158 const store = useComposerDraftStore.getState();
10451159
1046− store.setRuntimeMode(threadId, "approval-required");
1047− store.setInteractionMode(threadId, "plan");
1048− store.setRuntimeMode(threadId, null);
1049− store.setInteractionMode(threadId, null);
1160+ store.setRuntimeMode(threadRef, "approval-required");
1161+ store.setInteractionMode(threadRef, "plan");
1162+ store.setRuntimeMode(threadRef, null);
1163+ store.setInteractionMode(threadRef, null);
10501164
1051− expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
1165+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
10521166 });
10531167 });
10541168
apps/web/src/composerDraftStore.ts+1572−948
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
33 type ClaudeCodeEffort,
44 type CodexReasoningEffort,
55 DEFAULT_MODEL_BY_PROVIDER,
6+ type EnvironmentId,
67 ModelSelection,
78 ProjectId,
89 ProviderInteractionMode,
@@ -10,8 +11,18 @@ import { · 1 unmodified line
1011 ProviderModelOptions,
1112 RuntimeMode,
1213 type ServerProvider,
14+ type ScopedProjectRef,
15+ type ScopedThreadRef,
1316 ThreadId,
1417 } from "@t3tools/contracts";
18+import {
19+ parseScopedProjectKey,
20+ parseScopedThreadKey,
21+ scopedProjectKey,
22+ scopeProjectRef,
23+ scopedThreadKey,
24+ scopeThreadRef,
25+} from "@t3tools/client-runtime";
1526 import * as Schema from "effect/Schema";
1627 import * as Equal from "effect/Equal";
1728 import { DeepMutable } from "effect/Types";
@@ -32,10 +43,13 @@ import { getDefaultServerModel } from "./providerModels"; · 14 unmodified lines
3243 import { UnifiedSettings } from "@t3tools/contracts/settings";
3344
3445 export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
35−const COMPOSER_DRAFT_STORAGE_VERSION = 3;
46+const COMPOSER_DRAFT_STORAGE_VERSION = 5;
3647 const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
3748 export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
3849
50+export const DraftId = Schema.String.pipe(Schema.brand("DraftId"));
51+export type DraftId = typeof DraftId.Type;
52+
3953 const COMPOSER_PERSIST_DEBOUNCE_MS = 300;
4054
4155 const composerDebouncedStorage = createDebouncedStorage(
@@ -44,7 +58,7 @@ const composerDebouncedStorage = createDebouncedStorage( · 2 unmodified lines
4458 );
4559
4660 // Flush pending composer draft writes before page unload to prevent data loss.
47−if (typeof window !== "undefined") {
61+if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
4862 window.addEventListener("beforeunload", () => {
4963 composerDebouncedStorage.flush();
5064 });
@@ -122,6 +136,14 @@ type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; · 71 unmodified lines
122136 type LegacyV2StoreFields = {
123137 stickyModelSelection?: ModelSelection | null;
124138 stickyModelOptions?: ProviderModelOptions | null;
139+ projectDraftThreadIdByProjectId?: Record<string, string> | null;
140+ draftsByThreadId?: Record<string, PersistedComposerThreadDraftState> | null;
141+ draftThreadsByThreadId?: Record<string, PersistedDraftThreadState> | null;
142+ projectDraftThreadIdByProjectKey?: Record<string, string> | null;
143+ draftsByThreadKey?: Record<string, PersistedComposerThreadDraftState> | null;
144+ draftThreadsByThreadKey?: Record<string, PersistedDraftThreadState> | null;
145+ projectDraftThreadKeyByProjectKey?: Record<string, string> | null;
146+ logicalProjectDraftThreadKeyByLogicalProjectKey?: Record<string, string> | null;
125147 };
126148
127149 type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState &
@@ -129,20 +151,31 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & · 1 unmodified line
129151 LegacyV2StoreFields;
130152
131153 const PersistedDraftThreadState = Schema.Struct({
154+ threadId: ThreadId,
155+ environmentId: Schema.String,
132156 projectId: ProjectId,
157+ logicalProjectKey: Schema.optionalKey(Schema.String),
133158 createdAt: Schema.String,
134159 runtimeMode: RuntimeMode,
135160 interactionMode: ProviderInteractionMode,
136161 branch: Schema.NullOr(Schema.String),
137162 worktreePath: Schema.NullOr(Schema.String),
138163 envMode: DraftThreadEnvModeSchema,
164+ promotedTo: Schema.optionalKey(
165+ Schema.NullOr(
166+ Schema.Struct({
167+ environmentId: Schema.String,
168+ threadId: Schema.String,
169+ }),
170+ ),
171+ ),
139172 });
140173 type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type;
141174
142175 const PersistedComposerDraftStoreState = Schema.Struct({
143− draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState),
144− draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState),
145− projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId),
176+ draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState),
177+ draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState),
178+ logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String),
146179 stickyModelSelectionByProvider: Schema.optionalKey(
147180 Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)),
148181 ),
@@ -155,6 +188,10 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ · 6 unmodified lines
155188 state: PersistedComposerDraftStoreState,
156189 });
157190
191+/**
192+ * Composer content keyed by either a draft session (`DraftId`) or a real server
193+ * thread (`ScopedThreadRef`). This is the editable payload shown in the composer.
194+ */
158195 export interface ComposerThreadDraftState {
159196 prompt: string;
160197 images: ComposerImageAttachment[];
@@ -167,32 +204,94 @@ export interface ComposerThreadDraftState { · 6 unmodified lines
167204 interactionMode: ProviderInteractionMode | null;
168205 }
169206
170−export interface DraftThreadState {
207+/**
208+ * Mutable routing and execution context for a pre-thread draft session.
209+ *
210+ * Unlike a real server thread, a draft session can still change target
211+ * environment/worktree configuration before the first send.
212+ */
213+export interface DraftSessionState {
214+ threadId: ThreadId;
215+ environmentId: EnvironmentId;
171216 projectId: ProjectId;
217+ logicalProjectKey: string;
172218 createdAt: string;
173219 runtimeMode: RuntimeMode;
174220 interactionMode: ProviderInteractionMode;
175221 branch: string | null;
176222 worktreePath: string | null;
177223 envMode: DraftThreadEnvMode;
224+ promotedTo?: ScopedThreadRef | null;
178225 }
179226
180−interface ProjectDraftThread extends DraftThreadState {
181− threadId: ThreadId;
227+export type DraftThreadState = DraftSessionState;
228+
229+/**
230+ * Draft session metadata paired with its stable draft-session identity.
231+ */
232+interface ProjectDraftSession extends DraftSessionState {
233+ draftId: DraftId;
182234 }
183235
236+/**
237+ * App-facing composer identity:
238+ * - `DraftId` for pre-thread draft sessions
239+ * - `ScopedThreadRef` for server-backed threads
240+ *
241+ * Raw `ThreadId` is intentionally excluded so callers cannot drop environment
242+ * identity for real threads.
243+ */
244+type ComposerThreadTarget = ScopedThreadRef | DraftId;
245+
246+/**
247+ * Persisted store for composer content plus draft-session metadata.
248+ *
249+ * The store intentionally models two domains:
250+ * - draft sessions keyed by `DraftId`
251+ * - server thread composer state keyed by `ScopedThreadRef`
252+ */
184253 interface ComposerDraftStoreState {
185− draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
186− draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;
187− projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
254+ draftsByThreadKey: Record<string, ComposerThreadDraftState>;
255+ draftThreadsByThreadKey: Record<string, DraftThreadState>;
256+ logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>;
188257 stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
189258 stickyActiveProvider: ProviderKind | null;
190− getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null;
191− getDraftThread: (threadId: ThreadId) => DraftThreadState | null;
259+ /** Returns the editable composer content for a draft session or server thread. */
260+ getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null;
261+ /** Looks up the active draft session for a logical project identity. */
262+ getDraftThreadByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null;
263+ getDraftSessionByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null;
264+ getDraftThreadByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null;
265+ getDraftSessionByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null;
266+ /** Reads mutable draft-session metadata by `DraftId`. */
267+ getDraftSession: (draftId: DraftId) => DraftSessionState | null;
268+ /** Resolves a server-thread ref back to a matching draft session when one exists. */
269+ getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null;
270+ getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null;
271+ getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null;
272+ listDraftThreadKeys: () => string[];
273+ hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean;
274+ /** Creates or updates the draft session tracked for a logical project. */
275+ setLogicalProjectDraftThreadId: (
276+ logicalProjectKey: string,
277+ projectRef: ScopedProjectRef,
278+ draftId: DraftId,
279+ options?: {
280+ threadId?: ThreadId;
281+ branch?: string | null;
282+ worktreePath?: string | null;
283+ createdAt?: string;
284+ envMode?: DraftThreadEnvMode;
285+ runtimeMode?: RuntimeMode;
286+ interactionMode?: ProviderInteractionMode;
287+ },
288+ ) => void;
289+ /** Creates or updates the draft session tracked for a concrete project ref. */
192290 setProjectDraftThreadId: (
193− projectId: ProjectId,
194− threadId: ThreadId,
291+ projectRef: ScopedProjectRef,
292+ draftId: DraftId,
195293 options?: {
294+ threadId?: ThreadId;
196295 branch?: string | null;
197296 worktreePath?: string | null;
198297 createdAt?: string;
@@ -201,65 +300,76 @@ interface ComposerDraftStoreState { · 2 unmodified lines
201300 interactionMode?: ProviderInteractionMode;
202301 },
203302 ) => void;
303+ /** Updates mutable draft-session metadata without touching composer content. */
204304 setDraftThreadContext: (
205− threadId: ThreadId,
305+ threadRef: ComposerThreadTarget,
206306 options: {
207307 branch?: string | null;
208308 worktreePath?: string | null;
209− projectId?: ProjectId;
309+ projectRef?: ScopedProjectRef;
210310 createdAt?: string;
211311 envMode?: DraftThreadEnvMode;
212312 runtimeMode?: RuntimeMode;
213313 interactionMode?: ProviderInteractionMode;
214314 },
215315 ) => void;
216− clearProjectDraftThreadId: (projectId: ProjectId) => void;
217− clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
218− clearDraftThread: (threadId: ThreadId) => void;
316+ clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void;
317+ clearProjectDraftThreadById: (
318+ projectRef: ScopedProjectRef,
319+ threadRef: ComposerThreadTarget,
320+ ) => void;
321+ /** Marks a draft session as being promoted to a real server thread. */
322+ markDraftThreadPromoting: (threadRef: ComposerThreadTarget, promotedTo?: ScopedThreadRef) => void;
323+ /** Removes draft-session metadata after promotion is complete. */
324+ finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void;
325+ clearDraftThread: (threadRef: ComposerThreadTarget) => void;
219326 setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void;
220− setPrompt: (threadId: ThreadId, prompt: string) => void;
221− setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;
327+ setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void;
328+ setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void;
222329 setModelSelection: (
223− threadId: ThreadId,
330+ threadRef: ComposerThreadTarget,
224331 modelSelection: ModelSelection | null | undefined,
225332 ) => void;
226333 setModelOptions: (
227− threadId: ThreadId,
334+ threadRef: ComposerThreadTarget,
228335 modelOptions: ProviderModelOptions | null | undefined,
229336 ) => void;
230− applyStickyState: (threadId: ThreadId) => void;
337+ applyStickyState: (threadRef: ComposerThreadTarget) => void;
231338 setProviderModelOptions: (
232− threadId: ThreadId,
339+ threadRef: ComposerThreadTarget,
233340 provider: ProviderKind,
234341 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
235342 options?: {
236343 persistSticky?: boolean;
237344 },
238345 ) => void;
239− setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void;
346+ setRuntimeMode: (
347+ threadRef: ComposerThreadTarget,
348+ runtimeMode: RuntimeMode | null | undefined,
349+ ) => void;
240350 setInteractionMode: (
241− threadId: ThreadId,
351+ threadRef: ComposerThreadTarget,
242352 interactionMode: ProviderInteractionMode | null | undefined,
243353 ) => void;
244− addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;
245− addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;
246− removeImage: (threadId: ThreadId, imageId: string) => void;
354+ addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void;
355+ addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void;
356+ removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void;
247357 insertTerminalContext: (
248− threadId: ThreadId,
358+ threadRef: ComposerThreadTarget,
249359 prompt: string,
250360 context: TerminalContextDraft,
251361 index: number,
252362 ) => boolean;
253− addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void;
254− addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;
255− removeTerminalContext: (threadId: ThreadId, contextId: string) => void;
256− clearTerminalContexts: (threadId: ThreadId) => void;
257− clearPersistedAttachments: (threadId: ThreadId) => void;
363+ addTerminalContext: (threadRef: ComposerThreadTarget, context: TerminalContextDraft) => void;
364+ addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void;
365+ removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void;
366+ clearTerminalContexts: (threadRef: ComposerThreadTarget) => void;
367+ clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void;
258368 syncPersistedAttachments: (
259− threadId: ThreadId,
369+ threadRef: ComposerThreadTarget,
260370 attachments: PersistedComposerImageAttachment[],
261371 ) => void;
262− clearComposerContent: (threadId: ThreadId) => void;
372+ clearComposerContent: (threadRef: ComposerThreadTarget) => void;
263373 }
264374
265375 export interface EffectiveComposerModelState {
@@ -293,9 +403,9 @@ function modelSelectionByProviderToOptions( · 27 unmodified lines
293403 }
294404
295405 const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({
296− draftsByThreadId: {},
297− draftThreadsByThreadId: {},
298− projectDraftThreadIdByProjectId: {},
406+ draftsByThreadKey: {},
407+ draftThreadsByThreadKey: {},
408+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
299409 stickyModelSelectionByProvider: {},
300410 stickyActiveProvider: null,
301411 });
@@ -748,35 +858,323 @@ function normalizeDraftThreadEnvMode( · 446 unmodified lines
748858 return fallbackWorktreePath ? "worktree" : "local";
749859 }
750860
861+function projectDraftKey(projectRef: ScopedProjectRef): string {
862+ return scopedProjectKey(projectRef);
863+}
864+
865+function logicalProjectDraftKey(logicalProjectKey: string): string {
866+ return logicalProjectKey.trim();
867+}
868+
869+/**
870+ * Runtime composer storage key for app-facing identities only.
871+ *
872+ * Draft sessions are keyed by `DraftId`. Real threads are keyed by
873+ * `ScopedThreadRef` so environment identity is always preserved.
874+ */
875+function composerTargetKey(target: ScopedThreadRef | DraftId): string {
876+ if (typeof target === "string") {
877+ return target.trim();
878+ }
879+ return scopedThreadKey(target);
880+}
881+
882+/**
883+ * Legacy persisted data may still be keyed by a raw `ThreadId`. This helper is
884+ * intentionally migration-only so live code cannot accidentally accept that
885+ * incomplete identity.
886+ */
887+function normalizeLegacyComposerStorageKey(
888+ threadKeyOrId: string,
889+ options?: {
890+ environmentId?: EnvironmentId;
891+ },
892+): string {
893+ const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
894+ if (parsedThreadRef) {
895+ return composerTargetKey(parsedThreadRef);
896+ }
897+ if (options?.environmentId) {
898+ return composerTargetKey(scopeThreadRef(options.environmentId, threadKeyOrId as ThreadId));
899+ }
900+ return threadKeyOrId;
901+}
902+
903+function composerThreadRefFromKey(threadKey: string): ScopedThreadRef | null {
904+ return parseScopedThreadKey(threadKey);
905+}
906+
907+type ComposerThreadLookupState = Pick<
908+ ComposerDraftStoreState,
909+ "draftsByThreadKey" | "draftThreadsByThreadKey"
910+>;
911+
912+function normalizeComposerTarget(
913+ state: ComposerThreadLookupState,
914+ target: ComposerThreadTarget,
915+): ComposerThreadTarget | null {
916+ if (typeof target === "string") {
917+ const draftId = target.trim();
918+ return draftId.length > 0 ? DraftId.makeUnsafe(draftId) : null;
919+ }
920+ return target;
921+}
922+
923+function resolveComposerDraftKey(
924+ state: ComposerThreadLookupState,
925+ target: ComposerThreadTarget,
926+): string | null {
927+ const normalizedTarget = normalizeComposerTarget(state, target);
928+ if (!normalizedTarget) {
929+ return null;
930+ }
931+ if (typeof normalizedTarget !== "string") {
932+ const scopedKey = composerTargetKey(normalizedTarget);
933+ if (state.draftsByThreadKey[scopedKey]) {
934+ return scopedKey;
935+ }
936+ for (const [draftId, draftSession] of Object.entries(state.draftThreadsByThreadKey)) {
937+ if (
938+ draftSession.environmentId === normalizedTarget.environmentId &&
939+ draftSession.threadId === normalizedTarget.threadId
940+ ) {
941+ return draftId;
942+ }
943+ }
944+ return scopedKey;
945+ }
946+ const threadKey = composerTargetKey(normalizedTarget);
947+ return threadKey.length > 0 ? threadKey : null;
948+}
949+
950+function resolveComposerThreadId(
951+ state: ComposerThreadLookupState,
952+ target: ComposerThreadTarget,
953+): ThreadId | null {
954+ const normalizedTarget = normalizeComposerTarget(state, target);
955+ if (!normalizedTarget) {
956+ return null;
957+ }
958+ if (typeof normalizedTarget !== "string") {
959+ return normalizedTarget.threadId;
960+ }
961+ return state.draftThreadsByThreadKey[normalizedTarget]?.threadId ?? null;
962+}
963+
964+function getComposerDraftState(
965+ state: Pick<ComposerDraftStoreState, "draftsByThreadKey" | "draftThreadsByThreadKey">,
966+ target: ComposerThreadTarget,
967+): ComposerThreadDraftState | null {
968+ const threadKey = resolveComposerDraftKey(state, target);
969+ if (!threadKey) {
970+ return null;
971+ }
972+ return state.draftsByThreadKey[threadKey] ?? null;
973+}
974+
975+function isComposerThreadKeyInUse(mappings: Record<string, string>, threadKey: string): boolean {
976+ return Object.values(mappings).includes(threadKey);
977+}
978+
979+function toProjectDraftSession(
980+ draftId: DraftId,
981+ draftSession: DraftSessionState,
982+): ProjectDraftSession {
983+ return {
984+ draftId,
985+ ...draftSession,
986+ };
987+}
988+
989+function createDraftThreadState(
990+ projectRef: ScopedProjectRef,
991+ threadId: ThreadId,
992+ logicalProjectKey: string,
993+ existingThread: DraftThreadState | undefined,
994+ options?: {
995+ threadId?: ThreadId;
996+ branch?: string | null;
997+ worktreePath?: string | null;
998+ createdAt?: string;
999+ envMode?: DraftThreadEnvMode;
1000+ runtimeMode?: RuntimeMode;
1001+ interactionMode?: ProviderInteractionMode;
1002+ },
1003+): DraftThreadState {
1004+ const nextWorktreePath =
1005+ options?.worktreePath === undefined
1006+ ? (existingThread?.worktreePath ?? null)
1007+ : (options.worktreePath ?? null);
1008+ return {
1009+ threadId,
1010+ environmentId: projectRef.environmentId,
1011+ projectId: projectRef.projectId,
1012+ logicalProjectKey,
1013+ createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
1014+ runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
1015+ interactionMode:
1016+ options?.interactionMode ?? existingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE,
1017+ branch:
1018+ options?.branch === undefined ? (existingThread?.branch ?? null) : (options.branch ?? null),
1019+ worktreePath: nextWorktreePath,
1020+ envMode:
1021+ options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")),
1022+ promotedTo: null,
1023+ };
1024+}
1025+
1026+function scopedThreadRefsEqual(
1027+ left: ScopedThreadRef | null | undefined,
1028+ right: ScopedThreadRef | null | undefined,
1029+): boolean {
1030+ if (!left || !right) {
1031+ return left === right;
1032+ }
1033+ return left.environmentId === right.environmentId && left.threadId === right.threadId;
1034+}
1035+
1036+function isDraftThreadPromoting(draftThread: DraftThreadState | null | undefined): boolean {
1037+ return draftThread?.promotedTo !== null && draftThread?.promotedTo !== undefined;
1038+}
1039+
1040+function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThreadState): boolean {
1041+ return (
1042+ !!left &&
1043+ left.threadId === right.threadId &&
1044+ left.environmentId === right.environmentId &&
1045+ left.projectId === right.projectId &&
1046+ left.logicalProjectKey === right.logicalProjectKey &&
1047+ left.createdAt === right.createdAt &&
1048+ left.runtimeMode === right.runtimeMode &&
1049+ left.interactionMode === right.interactionMode &&
1050+ left.branch === right.branch &&
1051+ left.worktreePath === right.worktreePath &&
1052+ left.envMode === right.envMode &&
1053+ scopedThreadRefsEqual(left.promotedTo, right.promotedTo)
1054+ );
1055+}
1056+
1057+function removeDraftThreadReferences(
1058+ state: Pick<
1059+ ComposerDraftStoreState,
1060+ | "draftThreadsByThreadKey"
1061+ | "draftsByThreadKey"
1062+ | "logicalProjectDraftThreadKeyByLogicalProjectKey"
1063+ >,
1064+ threadKey: string,
1065+): Pick<
1066+ ComposerDraftStoreState,
1067+ | "draftThreadsByThreadKey"
1068+ | "draftsByThreadKey"
1069+ | "logicalProjectDraftThreadKeyByLogicalProjectKey"
1070+> {
1071+ const nextLogicalMappings = Object.fromEntries(
1072+ Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter(
1073+ ([, draftThreadKey]) => draftThreadKey !== threadKey,
1074+ ),
1075+ ) as Record<string, string>;
1076+ const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } =
1077+ state.draftThreadsByThreadKey;
1078+ const { [threadKey]: _removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey;
1079+ return {
1080+ draftsByThreadKey: restDraftsByThreadKey,
1081+ draftThreadsByThreadKey: restDraftThreadsByThreadKey,
1082+ logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings,
1083+ };
1084+}
1085+
7511086 function normalizePersistedDraftThreads(
7521087 rawDraftThreadsByThreadId: unknown,
753− rawProjectDraftThreadIdByProjectId: unknown,
1088+ rawProjectDraftThreadIdByProjectKey: unknown,
7541089 ): Pick<
7551090 PersistedComposerDraftStoreState,
756− "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId"
1091+ "draftThreadsByThreadKey" | "logicalProjectDraftThreadKeyByLogicalProjectKey"
7571092 > {
758− const draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState> = {};
1093+ const draftThreadsByThreadKey: Record<string, PersistedDraftThreadState> = {};
1094+ const environmentIdByThreadId = new Map<ThreadId, EnvironmentId>();
1095+ if (
1096+ rawProjectDraftThreadIdByProjectKey &&
1097+ typeof rawProjectDraftThreadIdByProjectKey === "object"
1098+ ) {
1099+ for (const [projectKey, threadId] of Object.entries(
1100+ rawProjectDraftThreadIdByProjectKey as Record<string, unknown>,
1101+ )) {
1102+ if (typeof threadId !== "string" || threadId.length === 0) {
1103+ continue;
1104+ }
1105+ const projectRef = parseScopedProjectKey(projectKey);
1106+ if (!projectRef) {
1107+ continue;
1108+ }
1109+ const parsedThreadRef = parseScopedThreadKey(threadId);
1110+ if (parsedThreadRef) {
1111+ environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId);
1112+ continue;
1113+ }
1114+ environmentIdByThreadId.set(threadId as ThreadId, projectRef.environmentId);
1115+ }
1116+ }
7591117 if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") {
760− for (const [threadId, rawDraftThread] of Object.entries(
1118+ for (const [threadKeyOrId, rawDraftThread] of Object.entries(
7611119 rawDraftThreadsByThreadId as Record<string, unknown>,
7621120 )) {
763− if (typeof threadId !== "string" || threadId.length === 0) {
1121+ if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
7641122 continue;
7651123 }
7661124 if (!rawDraftThread || typeof rawDraftThread !== "object") {
7671125 continue;
7681126 }
7691127 const candidateDraftThread = rawDraftThread as Record<string, unknown>;
1128+ const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
1129+ const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId);
1130+ const threadId =
1131+ parsedThreadRef?.threadId ??
1132+ (typeof candidateDraftThread.threadId === "string" &&
1133+ candidateDraftThread.threadId.length > 0
1134+ ? (candidateDraftThread.threadId as ThreadId)
1135+ : (threadKeyOrId as ThreadId));
1136+ const environmentId =
1137+ parsedThreadRef?.environmentId ??
1138+ (typeof candidateDraftThread.environmentId === "string" &&
1139+ candidateDraftThread.environmentId.length > 0
1140+ ? (candidateDraftThread.environmentId as EnvironmentId)
1141+ : environmentIdByThreadId.get(threadKeyOrId as ThreadId));
7701142 const projectId = candidateDraftThread.projectId;
7711143 const createdAt = candidateDraftThread.createdAt;
7721144 const branch = candidateDraftThread.branch;
7731145 const worktreePath = candidateDraftThread.worktreePath;
7741146 const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null;
775− if (typeof projectId !== "string" || projectId.length === 0) {
1147+ const promotedToCandidate = candidateDraftThread.promotedTo;
1148+ const promotedToRecord =
1149+ promotedToCandidate && typeof promotedToCandidate === "object"
1150+ ? (promotedToCandidate as Record<string, unknown>)
1151+ : null;
1152+ const promotedTo =
1153+ promotedToRecord &&
1154+ typeof promotedToRecord.environmentId === "string" &&
1155+ promotedToRecord.environmentId.length > 0 &&
1156+ typeof promotedToRecord.threadId === "string" &&
1157+ promotedToRecord.threadId.length > 0
1158+ ? scopeThreadRef(
1159+ promotedToRecord.environmentId as EnvironmentId,
1160+ promotedToRecord.threadId as ThreadId,
1161+ )
1162+ : null;
1163+ if (typeof projectId !== "string" || projectId.length === 0 || environmentId === undefined) {
7761164 continue;
7771165 }
778− draftThreadsByThreadId[threadId as ThreadId] = {
1166+ const normalizedEnvironmentId = environmentId as EnvironmentId;
1167+ draftThreadsByThreadKey[threadKey] = {
1168+ threadId,
1169+ environmentId: normalizedEnvironmentId,
7791170 projectId: projectId as ProjectId,
1171+ logicalProjectKey:
1172+ typeof candidateDraftThread.logicalProjectKey === "string" &&
1173+ candidateDraftThread.logicalProjectKey.length > 0
1174+ ? candidateDraftThread.logicalProjectKey
1175+ : parsedThreadRef
1176+ ? projectDraftKey(scopeProjectRef(normalizedEnvironmentId, projectId as ProjectId))
1177+ : threadKeyOrId,
7801178 createdAt:
7811179 typeof createdAt === "string" && createdAt.length > 0
7821180 ? createdAt
@@ -794,59 +1192,97 @@ function normalizePersistedDraftThreads( · 11 unmodified lines
7941192 branch: typeof branch === "string" ? branch : null,
7951193 worktreePath: normalizedWorktreePath,
7961194 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
1195+ promotedTo,
7971196 };
7981197 }
7991198 }
8001199
801− const projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {};
1200+ const logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> = {};
8021201 if (
803− rawProjectDraftThreadIdByProjectId &&
804− typeof rawProjectDraftThreadIdByProjectId === "object"
1202+ rawProjectDraftThreadIdByProjectKey &&
1203+ typeof rawProjectDraftThreadIdByProjectKey === "object"
8051204 ) {
806− for (const [projectId, threadId] of Object.entries(
807− rawProjectDraftThreadIdByProjectId as Record<string, unknown>,
1205+ for (const [logicalProjectKey, threadKeyOrId] of Object.entries(
1206+ rawProjectDraftThreadIdByProjectKey as Record<string, unknown>,
8081207 )) {
809− if (
810− typeof projectId === "string" &&
811− projectId.length > 0 &&
812− typeof threadId === "string" &&
813− threadId.length > 0
814− ) {
815− projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId;
816− if (!draftThreadsByThreadId[threadId as ThreadId]) {
817− draftThreadsByThreadId[threadId as ThreadId] = {
818− projectId: projectId as ProjectId,
819− createdAt: new Date().toISOString(),
820− runtimeMode: DEFAULT_RUNTIME_MODE,
821− interactionMode: DEFAULT_INTERACTION_MODE,
822− branch: null,
823− worktreePath: null,
824− envMode: "local",
825− };
826− } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) {
827− draftThreadsByThreadId[threadId as ThreadId] = {
828− ...draftThreadsByThreadId[threadId as ThreadId]!,
829− projectId: projectId as ProjectId,
1208+ if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
1209+ continue;
1210+ }
1211+ const projectRef = parseScopedProjectKey(logicalProjectKey);
1212+ const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
1213+ const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId);
1214+ logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey;
1215+ if (parsedThreadRef) {
1216+ environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId);
1217+ }
1218+ if (!projectRef) {
1219+ const existingDraftThread = draftThreadsByThreadKey[threadKey];
1220+ if (existingDraftThread && !existingDraftThread.logicalProjectKey) {
1221+ draftThreadsByThreadKey[threadKey] = {
1222+ ...existingDraftThread,
1223+ logicalProjectKey,
8301224 };
8311225 }
1226+ continue;
1227+ }
1228+ if (!draftThreadsByThreadKey[threadKey]) {
1229+ draftThreadsByThreadKey[threadKey] = {
1230+ threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId),
1231+ environmentId: projectRef.environmentId,
1232+ projectId: projectRef.projectId,
1233+ logicalProjectKey,
1234+ createdAt: new Date().toISOString(),
1235+ runtimeMode: DEFAULT_RUNTIME_MODE,
1236+ interactionMode: DEFAULT_INTERACTION_MODE,
1237+ branch: null,
1238+ worktreePath: null,
1239+ envMode: "local",
1240+ promotedTo: null,
1241+ };
1242+ } else if (
1243+ draftThreadsByThreadKey[threadKey]?.projectId !== projectRef.projectId ||
1244+ draftThreadsByThreadKey[threadKey]?.environmentId !== projectRef.environmentId
1245+ ) {
1246+ draftThreadsByThreadKey[threadKey] = {
1247+ ...draftThreadsByThreadKey[threadKey]!,
1248+ threadId: draftThreadsByThreadKey[threadKey]!.threadId,
1249+ environmentId: projectRef.environmentId,
1250+ projectId: projectRef.projectId,
1251+ logicalProjectKey,
1252+ };
8321253 }
8331254 }
8341255 }
8351256
836− return { draftThreadsByThreadId, projectDraftThreadIdByProjectId };
1257+ return { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey };
8371258 }
8381259
8391260 function normalizePersistedDraftsByThreadId(
8401261 rawDraftMap: unknown,
841−): PersistedComposerDraftStoreState["draftsByThreadId"] {
1262+ draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"],
1263+): PersistedComposerDraftStoreState["draftsByThreadKey"] {
8421264 if (!rawDraftMap || typeof rawDraftMap !== "object") {
8431265 return {};
8441266 }
8451267
846− const nextDraftsByThreadId: DeepMutable<PersistedComposerDraftStoreState["draftsByThreadId"]> =
1268+ const environmentIdByThreadId = new Map<ThreadId, EnvironmentId>();
1269+ for (const [threadKey, draftThread] of Object.entries(draftThreadsByThreadKey)) {
1270+ const parsedThreadRef = composerThreadRefFromKey(threadKey);
1271+ if (!parsedThreadRef) {
1272+ continue;
1273+ }
1274+ environmentIdByThreadId.set(
1275+ parsedThreadRef.threadId,
1276+ draftThread.environmentId as EnvironmentId,
1277+ );
1278+ }
1279+
1280+ const nextDraftsByThreadKey: DeepMutable<PersistedComposerDraftStoreState["draftsByThreadKey"]> =
8471281 {};
848− for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) {
849− if (typeof threadId !== "string" || threadId.length === 0) {
1282+ for (const [threadKeyOrId, draftValue] of Object.entries(
1283+ rawDraftMap as Record<string, unknown>,
1284+ )) {
1285+ if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
8501286 continue;
8511287 }
8521288 if (!draftValue || typeof draftValue !== "object") {
@@ -937,7 +1373,19 @@ function normalizePersistedDraftsByThreadId( · 84 unmodified lines
9371373 ) {
9381374 continue;
9391375 }
940− nextDraftsByThreadId[threadId as ThreadId] = {
1376+ const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
1377+ const normalizedThreadKey =
1378+ parsedThreadRef !== null
1379+ ? normalizeLegacyComposerStorageKey(threadKeyOrId)
1380+ : draftThreadsByThreadKey[threadKeyOrId] !== undefined
1381+ ? threadKeyOrId
1382+ : (() => {
1383+ const environmentId = environmentIdByThreadId.get(threadKeyOrId as ThreadId);
1384+ return environmentId
1385+ ? normalizeLegacyComposerStorageKey(threadKeyOrId, { environmentId })
1386+ : threadKeyOrId;
1387+ })();
1388+ nextDraftsByThreadKey[normalizedThreadKey] = {
9411389 prompt,
9421390 attachments,
9431391 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
@@ -947,7 +1395,7 @@ function normalizePersistedDraftsByThreadId( · 3 unmodified lines
9471395 };
9481396 }
9491397
950− return nextDraftsByThreadId;
1398+ return nextDraftsByThreadKey;
9511399 }
9521400
9531401 function migratePersistedComposerDraftStoreState(
@@ -957,9 +1405,14 @@ function migratePersistedComposerDraftStoreState( · 3 unmodified lines
9571405 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
9581406 }
9591407 const candidate = persistedState as LegacyPersistedComposerDraftStoreState;
960− const rawDraftMap = candidate.draftsByThreadId;
961− const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
962− const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;
1408+ const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId;
1409+ const rawDraftThreadsByThreadId =
1410+ candidate.draftThreadsByThreadKey ?? candidate.draftThreadsByThreadId;
1411+ const rawProjectDraftThreadIdByProjectKey =
1412+ candidate.logicalProjectDraftThreadKeyByLogicalProjectKey ??
1413+ candidate.projectDraftThreadKeyByProjectKey ??
1414+ candidate.projectDraftThreadIdByProjectKey ??
1415+ candidate.projectDraftThreadIdByProjectId;
9631416
9641417 // Migrate sticky state from v2 (dual) to v3 (consolidated)
9651418 const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {};
@@ -982,13 +1435,16 @@ function migratePersistedComposerDraftStoreState( · 16 unmodified lines
9821435 );
9831436 const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null;
9841437
985− const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
986− normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId);
987− const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap);
1438+ const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } =
1439+ normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey);
1440+ const draftsByThreadKey = normalizePersistedDraftsByThreadId(
1441+ rawDraftMap,
1442+ draftThreadsByThreadKey,
1443+ );
9881444 return {
989− draftsByThreadId,
990− draftThreadsByThreadId,
991− projectDraftThreadIdByProjectId,
1445+ draftsByThreadKey,
1446+ draftThreadsByThreadKey,
1447+ logicalProjectDraftThreadKeyByLogicalProjectKey,
9921448 stickyModelSelectionByProvider,
9931449 stickyActiveProvider,
9941450 };
@@ -997,11 +1453,11 @@ function migratePersistedComposerDraftStoreState( · 2 unmodified lines
9971453 function partializeComposerDraftStoreState(
9981454 state: ComposerDraftStoreState,
9991455 ): PersistedComposerDraftStoreState {
1000− const persistedDraftsByThreadId: DeepMutable<
1001− PersistedComposerDraftStoreState["draftsByThreadId"]
1456+ const persistedDraftsByThreadKey: DeepMutable<
1457+ PersistedComposerDraftStoreState["draftsByThreadKey"]
10021458 > = {};
1003− for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {
1004− if (typeof threadId !== "string" || threadId.length === 0) {
1459+ for (const [threadKey, draft] of Object.entries(state.draftsByThreadKey)) {
1460+ if (typeof threadKey !== "string" || threadKey.length === 0) {
10051461 continue;
10061462 }
10071463 const hasModelData =
@@ -1041,12 +1497,13 @@ function partializeComposerDraftStoreState( · 33 unmodified lines
10411497 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),
10421498 ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}),
10431499 };
1044− persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;
1500+ persistedDraftsByThreadKey[threadKey] = persistedDraft;
10451501 }
10461502 return {
1047− draftsByThreadId: persistedDraftsByThreadId,
1048− draftThreadsByThreadId: state.draftThreadsByThreadId,
1049− projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
1503+ draftsByThreadKey: persistedDraftsByThreadKey,
1504+ draftThreadsByThreadKey: state.draftThreadsByThreadKey,
1505+ logicalProjectDraftThreadKeyByLogicalProjectKey:
1506+ state.logicalProjectDraftThreadKeyByLogicalProjectKey,
10501507 stickyModelSelectionByProvider: state.stickyModelSelectionByProvider,
10511508 stickyActiveProvider: state.stickyActiveProvider,
10521509 };
@@ -1059,10 +1516,14 @@ function normalizeCurrentPersistedComposerDraftStoreState( · 6 unmodified lines
10591516 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
10601517 }
10611518 const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState;
1062− const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
1519+ const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } =
10631520 normalizePersistedDraftThreads(
1064− normalizedPersistedState.draftThreadsByThreadId,
1065− normalizedPersistedState.projectDraftThreadIdByProjectId,
1521+ normalizedPersistedState.draftThreadsByThreadKey ??
1522+ normalizedPersistedState.draftThreadsByThreadId,
1523+ normalizedPersistedState.logicalProjectDraftThreadKeyByLogicalProjectKey ??
1524+ normalizedPersistedState.projectDraftThreadKeyByProjectKey ??
1525+ normalizedPersistedState.projectDraftThreadIdByProjectKey ??
1526+ normalizedPersistedState.projectDraftThreadIdByProjectId,
10661527 );
10671528
10681529 // Handle both v3 (modelSelectionByProvider) and v2/legacy formats
@@ -1105,16 +1566,19 @@ function normalizeCurrentPersistedComposerDraftStoreState( · 36 unmodified lines
11051566 }
11061567
11071568 return {
1108− draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId),
1109− draftThreadsByThreadId,
1110− projectDraftThreadIdByProjectId,
1569+ draftsByThreadKey: normalizePersistedDraftsByThreadId(
1570+ normalizedPersistedState.draftsByThreadKey ?? normalizedPersistedState.draftsByThreadId,
1571+ draftThreadsByThreadKey,
1572+ ),
1573+ draftThreadsByThreadKey,
1574+ logicalProjectDraftThreadKeyByLogicalProjectKey,
11111575 stickyModelSelectionByProvider,
11121576 stickyActiveProvider,
11131577 };
11141578 }
11151579
1116−function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
1117− if (threadId.length === 0) {
1580+function readPersistedAttachmentIdsFromStorage(threadKey: string): string[] {
1581+ if (threadKey.length === 0) {
11181582 return [];
11191583 }
11201584 try {
@@ -1125,7 +1589,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { · 4 unmodified lines
11251589 if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) {
11261590 return [];
11271591 }
1128− return (persisted.state.draftsByThreadId[threadId]?.attachments ?? []).map(
1592+ return (persisted.state.draftsByThreadKey[threadKey]?.attachments ?? []).map(
11291593 (attachment) => attachment.id,
11301594 );
11311595 } catch {
@@ -1134,7 +1598,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { · 2 unmodified lines
11341598 }
11351599
11361600 function verifyPersistedAttachments(
1137− threadId: ThreadId,
1601+ threadKey: string,
11381602 attachments: PersistedComposerImageAttachment[],
11391603 set: (
11401604 partial:
@@ -1149,12 +1613,12 @@ function verifyPersistedAttachments( · 8 unmodified lines
11491613 let persistedIdSet = new Set<string>();
11501614 try {
11511615 composerDebouncedStorage.flush();
1152− persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId));
1616+ persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadKey));
11531617 } catch {
11541618 persistedIdSet = new Set();
11551619 }
11561620 set((state) => {
1157− const current = state.draftsByThreadId[threadId];
1621+ const current = state.draftsByThreadKey[threadKey];
11581622 if (!current) {
11591623 return state;
11601624 }
@@ -1170,13 +1634,13 @@ function verifyPersistedAttachments( · 9 unmodified lines
11701634 persistedAttachments,
11711635 nonPersistedImageIds,
11721636 };
1173− const nextDraftsByThreadId = { ...state.draftsByThreadId };
1637+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
11741638 if (shouldRemoveDraft(nextDraft)) {
1175− delete nextDraftsByThreadId[threadId];
1639+ delete nextDraftsByThreadKey[threadKey];
11761640 } else {
1177− nextDraftsByThreadId[threadId] = nextDraft;
1641+ nextDraftsByThreadKey[threadKey] = nextDraft;
11781642 }
1179− return { draftsByThreadId: nextDraftsByThreadId };
1643+ return { draftsByThreadKey: nextDraftsByThreadKey };
11801644 });
11811645 }
11821646
@@ -1258,879 +1722,980 @@ function toHydratedThreadDraft( · 75 unmodified lines
12581722 };
12591723 }
12601724
1261−export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1725+function toHydratedDraftThreadState(
1726+ persistedDraftThread: PersistedDraftThreadState,
1727+): DraftThreadState {
1728+ return {
1729+ threadId: persistedDraftThread.threadId,
1730+ environmentId: persistedDraftThread.environmentId as EnvironmentId,
1731+ projectId: persistedDraftThread.projectId,
1732+ logicalProjectKey:
1733+ persistedDraftThread.logicalProjectKey ??
1734+ projectDraftKey(
1735+ scopeProjectRef(
1736+ persistedDraftThread.environmentId as EnvironmentId,
1737+ persistedDraftThread.projectId,
1738+ ),
1739+ ),
1740+ createdAt: persistedDraftThread.createdAt,
1741+ runtimeMode: persistedDraftThread.runtimeMode,
1742+ interactionMode: persistedDraftThread.interactionMode,
1743+ branch: persistedDraftThread.branch,
1744+ worktreePath: persistedDraftThread.worktreePath,
1745+ envMode: persistedDraftThread.envMode,
1746+ promotedTo: persistedDraftThread.promotedTo
1747+ ? scopeThreadRef(
1748+ persistedDraftThread.promotedTo.environmentId as EnvironmentId,
1749+ persistedDraftThread.promotedTo.threadId as ThreadId,
1750+ )
1751+ : null,
1752+ };
1753+}
1754+
1755+const composerDraftStore = create<ComposerDraftStoreState>()(
12621756 persist(
1263− (set, get) => ({
1264− draftsByThreadId: {},
1265− draftThreadsByThreadId: {},
1266− projectDraftThreadIdByProjectId: {},
1267− stickyModelSelectionByProvider: {},
1268− stickyActiveProvider: null,
1269− getDraftThreadByProjectId: (projectId) => {
1270− if (projectId.length === 0) {
1271− return null;
1272− }
1273− const threadId = get().projectDraftThreadIdByProjectId[projectId];
1274− if (!threadId) {
1275− return null;
1276− }
1277− const draftThread = get().draftThreadsByThreadId[threadId];
1278− if (!draftThread || draftThread.projectId !== projectId) {
1279− return null;
1280− }
1281− return {
1282− threadId,
1283− ...draftThread,
1284− };
1285− },
1286− getDraftThread: (threadId) => {
1287− if (threadId.length === 0) {
1288− return null;
1289− }
1290− return get().draftThreadsByThreadId[threadId] ?? null;
1291− },
1292− setProjectDraftThreadId: (projectId, threadId, options) => {
1293− if (projectId.length === 0 || threadId.length === 0) {
1294− return;
1295− }
1296− set((state) => {
1297− const existingThread = state.draftThreadsByThreadId[threadId];
1298− const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId];
1299− const nextWorktreePath =
1300− options?.worktreePath === undefined
1301− ? (existingThread?.worktreePath ?? null)
1302− : (options.worktreePath ?? null);
1303− const nextDraftThread: DraftThreadState = {
1304− projectId,
1305− createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
1306− runtimeMode:
1307− options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
1308− interactionMode:
1309− options?.interactionMode ??
1310− existingThread?.interactionMode ??
1311− DEFAULT_INTERACTION_MODE,
1312− branch:
1313− options?.branch === undefined
1314− ? (existingThread?.branch ?? null)
1315− : (options.branch ?? null),
1316− worktreePath: nextWorktreePath,
1317− envMode:
1318− options?.envMode ??
1319− (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")),
1320− };
1321− const hasSameProjectMapping = previousThreadIdForProject === threadId;
1322− const hasSameDraftThread =
1323− existingThread &&
1324− existingThread.projectId === nextDraftThread.projectId &&
1325− existingThread.createdAt === nextDraftThread.createdAt &&
1326− existingThread.runtimeMode === nextDraftThread.runtimeMode &&
1327− existingThread.interactionMode === nextDraftThread.interactionMode &&
1328− existingThread.branch === nextDraftThread.branch &&
1329− existingThread.worktreePath === nextDraftThread.worktreePath &&
1330− existingThread.envMode === nextDraftThread.envMode;
1331− if (hasSameProjectMapping && hasSameDraftThread) {
1332− return state;
1333− }
1334− const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {
1335− ...state.projectDraftThreadIdByProjectId,
1336− [projectId]: threadId,
1337− };
1338− const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
1339− ...state.draftThreadsByThreadId,
1340− [threadId]: nextDraftThread,
1341− };
1342− let nextDraftsByThreadId = state.draftsByThreadId;
1343− if (
1344− previousThreadIdForProject &&
1345− previousThreadIdForProject !== threadId &&
1346− !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject)
1347− ) {
1348− delete nextDraftThreadsByThreadId[previousThreadIdForProject];
1349− if (state.draftsByThreadId[previousThreadIdForProject] !== undefined) {
1350− nextDraftsByThreadId = { ...state.draftsByThreadId };
1351− delete nextDraftsByThreadId[previousThreadIdForProject];
1352− }
1353− }
1354− return {
1355− draftsByThreadId: nextDraftsByThreadId,
1356− draftThreadsByThreadId: nextDraftThreadsByThreadId,
1357− projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
1358− };
1359− });
1360− },
1361− setDraftThreadContext: (threadId, options) => {
1362− if (threadId.length === 0) {
1363− return;
1364− }
1365− set((state) => {
1366− const existing = state.draftThreadsByThreadId[threadId];
1367− if (!existing) {
1368− return state;
1757+ (setBase, get) => {
1758+ const set = setBase;
1759+
1760+ return {
1761+ draftsByThreadKey: {},
1762+ draftThreadsByThreadKey: {},
1763+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
1764+ stickyModelSelectionByProvider: {},
1765+ stickyActiveProvider: null,
1766+ getComposerDraft: (target) => getComposerDraftState(get(), target),
1767+ getDraftThreadByLogicalProjectKey: (logicalProjectKey) => {
1768+ return get().getDraftSessionByLogicalProjectKey(logicalProjectKey);
1769+ },
1770+ getDraftSessionByLogicalProjectKey: (logicalProjectKey) => {
1771+ const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey);
1772+ if (normalizedLogicalProjectKey.length === 0) {
1773+ return null;
13691774 }
1370− const nextProjectId = options.projectId ?? existing.projectId;
1371− if (nextProjectId.length === 0) {
1372− return state;
1775+ const draftId =
1776+ get().logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey];
1777+ if (!draftId) {
1778+ return null;
13731779 }
1374− const nextWorktreePath =
1375− options.worktreePath === undefined
1376− ? existing.worktreePath
1377− : (options.worktreePath ?? null);
1378− const nextDraftThread: DraftThreadState = {
1379− projectId: nextProjectId,
1380− createdAt:
1381− options.createdAt === undefined
1382− ? existing.createdAt
1383− : options.createdAt || existing.createdAt,
1384− runtimeMode: options.runtimeMode ?? existing.runtimeMode,
1385− interactionMode: options.interactionMode ?? existing.interactionMode,
1386− branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
1387− worktreePath: nextWorktreePath,
1388− envMode:
1389− options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
1390− };
1391− const isUnchanged =
1392− nextDraftThread.projectId === existing.projectId &&
1393− nextDraftThread.createdAt === existing.createdAt &&
1394− nextDraftThread.runtimeMode === existing.runtimeMode &&
1395− nextDraftThread.interactionMode === existing.interactionMode &&
1396− nextDraftThread.branch === existing.branch &&
1397− nextDraftThread.worktreePath === existing.worktreePath &&
1398− nextDraftThread.envMode === existing.envMode;
1399− if (isUnchanged) {
1400− return state;
1780+ const draftThread = get().draftThreadsByThreadKey[draftId];
1781+ if (!draftThread || isDraftThreadPromoting(draftThread)) {
1782+ return null;
14011783 }
1402− const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {
1403− ...state.projectDraftThreadIdByProjectId,
1404− [nextProjectId]: threadId,
1405− };
1406− if (existing.projectId !== nextProjectId) {
1407− if (nextProjectDraftThreadIdByProjectId[existing.projectId] === threadId) {
1408− delete nextProjectDraftThreadIdByProjectId[existing.projectId];
1784+ return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread);
1785+ },
1786+ getDraftThreadByProjectRef: (projectRef) => {
1787+ return get().getDraftSessionByProjectRef(projectRef);
1788+ },
1789+ getDraftSessionByProjectRef: (projectRef) => {
1790+ for (const [draftId, draftThread] of Object.entries(get().draftThreadsByThreadKey)) {
1791+ if (isDraftThreadPromoting(draftThread)) {
1792+ continue;
14091793 }
1410− }
1411− return {
1412− draftThreadsByThreadId: {
1413− ...state.draftThreadsByThreadId,
1414− [threadId]: nextDraftThread,
1415− },
1416− projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
1417− };
1418− });
1419− },
1420− clearProjectDraftThreadId: (projectId) => {
1421− if (projectId.length === 0) {
1422− return;
1423− }
1424− set((state) => {
1425− const threadId = state.projectDraftThreadIdByProjectId[projectId];
1426− if (threadId === undefined) {
1427− return state;
1428− }
1429− const { [projectId]: _removed, ...restProjectMappingsRaw } =
1430− state.projectDraftThreadIdByProjectId;
1431− const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
1432− const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
1433− ...state.draftThreadsByThreadId,
1434− };
1435− let nextDraftsByThreadId = state.draftsByThreadId;
1436− if (!Object.values(restProjectMappings).includes(threadId)) {
1437− delete nextDraftThreadsByThreadId[threadId];
1438− if (state.draftsByThreadId[threadId] !== undefined) {
1439− nextDraftsByThreadId = { ...state.draftsByThreadId };
1440− delete nextDraftsByThreadId[threadId];
1794+ if (
1795+ draftThread.projectId === projectRef.projectId &&
1796+ draftThread.environmentId === projectRef.environmentId
1797+ ) {
1798+ return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread);
14411799 }
14421800 }
1443− return {
1444− draftsByThreadId: nextDraftsByThreadId,
1445− draftThreadsByThreadId: nextDraftThreadsByThreadId,
1446− projectDraftThreadIdByProjectId: restProjectMappings,
1447− };
1448− });
1449− },
1450− clearProjectDraftThreadById: (projectId, threadId) => {
1451− if (projectId.length === 0 || threadId.length === 0) {
1452− return;
1453− }
1454− set((state) => {
1455− if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) {
1456− return state;
1457− }
1458− const { [projectId]: _removed, ...restProjectMappingsRaw } =
1459− state.projectDraftThreadIdByProjectId;
1460− const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
1461− const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
1462− ...state.draftThreadsByThreadId,
1463− };
1464− let nextDraftsByThreadId = state.draftsByThreadId;
1465− if (!Object.values(restProjectMappings).includes(threadId)) {
1466− delete nextDraftThreadsByThreadId[threadId];
1467− if (state.draftsByThreadId[threadId] !== undefined) {
1468− nextDraftsByThreadId = { ...state.draftsByThreadId };
1469− delete nextDraftsByThreadId[threadId];
1801+ return null;
1802+ },
1803+ getDraftSession: (draftId) => get().draftThreadsByThreadKey[draftId] ?? null,
1804+ getDraftSessionByRef: (threadRef) => {
1805+ for (const draftSession of Object.values(get().draftThreadsByThreadKey)) {
1806+ if (
1807+ draftSession.environmentId === threadRef.environmentId &&
1808+ draftSession.threadId === threadRef.threadId
1809+ ) {
1810+ return draftSession;
14701811 }
14711812 }
1472− return {
1473− draftsByThreadId: nextDraftsByThreadId,
1474− draftThreadsByThreadId: nextDraftThreadsByThreadId,
1475− projectDraftThreadIdByProjectId: restProjectMappings,
1476− };
1477− });
1478− },
1479− clearDraftThread: (threadId) => {
1480− if (threadId.length === 0) {
1481− return;
1482− }
1483− const existing = get().draftsByThreadId[threadId];
1484− if (existing) {
1485− for (const image of existing.images) {
1486− revokeObjectPreviewUrl(image.previewUrl);
1487− }
1488− }
1489− set((state) => {
1490− const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined;
1491− const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(
1492− threadId,
1493− );
1494− const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined;
1495− if (!hasDraftThread && !hasProjectMapping && !hasComposerDraft) {
1496− return state;
1497− }
1498− const nextProjectDraftThreadIdByProjectId = Object.fromEntries(
1499− Object.entries(state.projectDraftThreadIdByProjectId).filter(
1500− ([, draftThreadId]) => draftThreadId !== threadId,
1501− ),
1502− ) as Record<ProjectId, ThreadId>;
1503− const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } =
1504− state.draftThreadsByThreadId;
1505− const { [threadId]: _removedComposerDraft, ...restDraftsByThreadId } =
1506− state.draftsByThreadId;
1507− return {
1508− draftsByThreadId: restDraftsByThreadId,
1509− draftThreadsByThreadId: restDraftThreadsByThreadId,
1510− projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
1511− };
1512− });
1513− },
1514− setStickyModelSelection: (modelSelection) => {
1515− const normalized = normalizeModelSelection(modelSelection);
1516− set((state) => {
1517− if (!normalized) {
1518− return state;
1813+ return null;
1814+ },
1815+ getDraftThread: (threadRef) => {
1816+ if (typeof threadRef === "string") {
1817+ return get().getDraftSession(DraftId.makeUnsafe(threadRef));
15191818 }
1520− const nextMap: Partial<Record<ProviderKind, ModelSelection>> = {
1521− ...state.stickyModelSelectionByProvider,
1522− [normalized.provider]: normalized,
1523− };
1524− if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {
1525− return state.stickyActiveProvider === normalized.provider
1526− ? state
1527− : { stickyActiveProvider: normalized.provider };
1819+ return get().getDraftSessionByRef(threadRef);
1820+ },
1821+ getDraftThreadByRef: (threadRef) => {
1822+ return get().getDraftSessionByRef(threadRef);
1823+ },
1824+ listDraftThreadKeys: () =>
1825+ Object.values(get().draftThreadsByThreadKey).map((draftThread) =>
1826+ scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)),
1827+ ),
1828+ hasDraftThreadsInEnvironment: (environmentId) =>
1829+ Object.values(get().draftThreadsByThreadKey).some(
1830+ (draftThread) => draftThread.environmentId === environmentId,
1831+ ),
1832+ setLogicalProjectDraftThreadId: (logicalProjectKey, projectRef, draftId, options) => {
1833+ const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey);
1834+ if (normalizedLogicalProjectKey.length === 0 || draftId.length === 0) {
1835+ return;
15281836 }
1529− return {
1530− stickyModelSelectionByProvider: nextMap,
1531− stickyActiveProvider: normalized.provider,
1532− };
1533− });
1534− },
1535− applyStickyState: (threadId) => {
1536− if (threadId.length === 0) {
1537− return;
1538− }
1539− set((state) => {
1540− const stickyMap = state.stickyModelSelectionByProvider;
1541− const stickyActiveProvider = state.stickyActiveProvider;
1542− if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) {
1543− return state;
1837+ set((state) => {
1838+ const existingThread = state.draftThreadsByThreadKey[draftId];
1839+ const previousThreadKeyForLogicalProject =
1840+ state.logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey];
1841+ const nextDraftThread = createDraftThreadState(
1842+ projectRef,
1843+ options?.threadId ?? existingThread?.threadId ?? ThreadId.makeUnsafe(draftId),
1844+ normalizedLogicalProjectKey,
1845+ existingThread,
1846+ options,
1847+ );
1848+ const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId;
1849+ if (hasSameLogicalMapping && draftThreadsEqual(existingThread, nextDraftThread)) {
1850+ return state;
1851+ }
1852+ const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> = {
1853+ ...state.logicalProjectDraftThreadKeyByLogicalProjectKey,
1854+ [normalizedLogicalProjectKey]: draftId,
1855+ };
1856+ const nextDraftThreadsByThreadKey: Record<string, DraftThreadState> = {
1857+ ...state.draftThreadsByThreadKey,
1858+ [draftId]: nextDraftThread,
1859+ };
1860+ let nextDraftsByThreadKey = state.draftsByThreadKey;
1861+ if (
1862+ previousThreadKeyForLogicalProject &&
1863+ previousThreadKeyForLogicalProject !== draftId &&
1864+ !isComposerThreadKeyInUse(
1865+ nextLogicalProjectDraftThreadKeyByLogicalProjectKey,
1866+ previousThreadKeyForLogicalProject,
1867+ )
1868+ ) {
1869+ delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
1870+ if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) {
1871+ nextDraftsByThreadKey = { ...state.draftsByThreadKey };
1872+ delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject];
1873+ }
1874+ }
1875+ return {
1876+ draftsByThreadKey: nextDraftsByThreadKey,
1877+ draftThreadsByThreadKey: nextDraftThreadsByThreadKey,
1878+ logicalProjectDraftThreadKeyByLogicalProjectKey:
1879+ nextLogicalProjectDraftThreadKeyByLogicalProjectKey,
1880+ };
1881+ });
1882+ },
1883+ setProjectDraftThreadId: (projectRef, draftId, options) => {
1884+ get().setLogicalProjectDraftThreadId(
1885+ projectDraftKey(projectRef),
1886+ projectRef,
1887+ draftId,
1888+ options,
1889+ );
1890+ },
1891+ setDraftThreadContext: (threadRef, options) => {
1892+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
1893+ if (threadKey.length === 0) {
1894+ return;
15441895 }
1545− const existing = state.draftsByThreadId[threadId];
1546− const base = existing ?? createEmptyThreadDraft();
1547− const nextMap = { ...base.modelSelectionByProvider };
1548− for (const [provider, selection] of Object.entries(stickyMap)) {
1549− if (selection) {
1550− const current = nextMap[provider as ProviderKind];
1551− nextMap[provider as ProviderKind] = {
1552− ...selection,
1553− model: current?.model ?? selection.model,
1554− };
1896+ set((state) => {
1897+ const existing = state.draftThreadsByThreadKey[threadKey];
1898+ if (!existing) {
1899+ return state;
15551900 }
1901+ const nextProjectRef = options.projectRef ?? {
1902+ environmentId: existing.environmentId,
1903+ projectId: existing.projectId,
1904+ };
1905+ if (
1906+ nextProjectRef.projectId.length === 0 ||
1907+ nextProjectRef.environmentId.length === 0
1908+ ) {
1909+ return state;
1910+ }
1911+ const nextWorktreePath =
1912+ options.worktreePath === undefined
1913+ ? existing.worktreePath
1914+ : (options.worktreePath ?? null);
1915+ const nextDraftThread: DraftThreadState = {
1916+ threadId: existing.threadId,
1917+ environmentId: nextProjectRef.environmentId,
1918+ projectId: nextProjectRef.projectId,
1919+ logicalProjectKey: existing.logicalProjectKey,
1920+ createdAt:
1921+ options.createdAt === undefined
1922+ ? existing.createdAt
1923+ : options.createdAt || existing.createdAt,
1924+ runtimeMode: options.runtimeMode ?? existing.runtimeMode,
1925+ interactionMode: options.interactionMode ?? existing.interactionMode,
1926+ branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
1927+ worktreePath: nextWorktreePath,
1928+ envMode:
1929+ options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
1930+ promotedTo: existing.promotedTo ?? null,
1931+ };
1932+ const isUnchanged =
1933+ nextDraftThread.environmentId === existing.environmentId &&
1934+ nextDraftThread.projectId === existing.projectId &&
1935+ nextDraftThread.logicalProjectKey === existing.logicalProjectKey &&
1936+ nextDraftThread.createdAt === existing.createdAt &&
1937+ nextDraftThread.runtimeMode === existing.runtimeMode &&
1938+ nextDraftThread.interactionMode === existing.interactionMode &&
1939+ nextDraftThread.branch === existing.branch &&
1940+ nextDraftThread.worktreePath === existing.worktreePath &&
1941+ nextDraftThread.envMode === existing.envMode &&
1942+ scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo);
1943+ if (isUnchanged) {
1944+ return state;
1945+ }
1946+ return {
1947+ draftThreadsByThreadKey: {
1948+ ...state.draftThreadsByThreadKey,
1949+ [threadKey]: nextDraftThread,
1950+ },
1951+ };
1952+ });
1953+ },
1954+ clearProjectDraftThreadId: (projectRef) => {
1955+ set((state) => {
1956+ const matchingThreadEntry = Object.entries(state.draftThreadsByThreadKey).find(
1957+ ([, draftThread]) =>
1958+ draftThread.projectId === projectRef.projectId &&
1959+ draftThread.environmentId === projectRef.environmentId,
1960+ );
1961+ if (!matchingThreadEntry) {
1962+ return state;
1963+ }
1964+ return removeDraftThreadReferences(state, matchingThreadEntry[0]);
1965+ });
1966+ },
1967+ clearProjectDraftThreadById: (projectRef, threadRef) => {
1968+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
1969+ if (threadKey.length === 0) {
1970+ return;
15561971 }
1557− if (
1558− Equal.equals(base.modelSelectionByProvider, nextMap) &&
1559− base.activeProvider === stickyActiveProvider
1560− ) {
1561− return state;
1972+ set((state) => {
1973+ const draftThread = state.draftThreadsByThreadKey[threadKey];
1974+ if (
1975+ !draftThread ||
1976+ draftThread.projectId !== projectRef.projectId ||
1977+ draftThread.environmentId !== projectRef.environmentId
1978+ ) {
1979+ return state;
1980+ }
1981+ return removeDraftThreadReferences(state, threadKey);
1982+ });
1983+ },
1984+ markDraftThreadPromoting: (threadRef, promotedTo) => {
1985+ const threadKey = resolveComposerDraftKey(get(), threadRef);
1986+ if (!threadKey) {
1987+ return;
15621988 }
1563− const nextDraft: ComposerThreadDraftState = {
1564− ...base,
1565− modelSelectionByProvider: nextMap,
1566− activeProvider: stickyActiveProvider,
1567− };
1568− const nextDraftsByThreadId = { ...state.draftsByThreadId };
1569− if (shouldRemoveDraft(nextDraft)) {
1570− delete nextDraftsByThreadId[threadId];
1571− } else {
1572− nextDraftsByThreadId[threadId] = nextDraft;
1989+ set((state) => {
1990+ const existing = state.draftThreadsByThreadKey[threadKey];
1991+ if (!existing) {
1992+ return state;
1993+ }
1994+ const nextPromotedTo =
1995+ promotedTo ?? scopeThreadRef(existing.environmentId, existing.threadId);
1996+ if (scopedThreadRefsEqual(existing.promotedTo, nextPromotedTo)) {
1997+ return state;
1998+ }
1999+ return {
2000+ draftThreadsByThreadKey: {
2001+ ...state.draftThreadsByThreadKey,
2002+ [threadKey]: {
2003+ ...existing,
2004+ promotedTo: nextPromotedTo,
2005+ },
2006+ },
2007+ };
2008+ });
2009+ },
2010+ finalizePromotedDraftThread: (threadRef) => {
2011+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2012+ if (threadKey.length === 0) {
2013+ return;
15732014 }
1574− return { draftsByThreadId: nextDraftsByThreadId };
1575− });
1576− },
1577− setPrompt: (threadId, prompt) => {
1578− if (threadId.length === 0) {
1579− return;
1580− }
1581− set((state) => {
1582− const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1583− const nextDraft: ComposerThreadDraftState = {
1584− ...existing,
1585− prompt,
1586− };
1587− const nextDraftsByThreadId = { ...state.draftsByThreadId };
1588− if (shouldRemoveDraft(nextDraft)) {
1589− delete nextDraftsByThreadId[threadId];
1590− } else {
1591− nextDraftsByThreadId[threadId] = nextDraft;
2015+ set((state) => {
2016+ const existing = state.draftThreadsByThreadKey[threadKey];
2017+ if (!isDraftThreadPromoting(existing)) {
2018+ return state;
2019+ }
2020+ return removeDraftThreadReferences(state, threadKey);
2021+ });
2022+ },
2023+ clearDraftThread: (threadRef) => {
2024+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2025+ if (threadKey.length === 0) {
2026+ return;
15922027 }
1593− return { draftsByThreadId: nextDraftsByThreadId };
1594− });
1595− },
1596− setTerminalContexts: (threadId, contexts) => {
1597− if (threadId.length === 0) {
1598− return;
1599− }
1600− const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts);
1601− set((state) => {
1602− const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1603− const nextDraft: ComposerThreadDraftState = {
1604− ...existing,
1605− prompt: ensureInlineTerminalContextPlaceholders(
1606− existing.prompt,
1607− normalizedContexts.length,
1608− ),
1609− terminalContexts: normalizedContexts,
1610− };
1611− const nextDraftsByThreadId = { ...state.draftsByThreadId };
1612− if (shouldRemoveDraft(nextDraft)) {
1613− delete nextDraftsByThreadId[threadId];
1614− } else {
1615− nextDraftsByThreadId[threadId] = nextDraft;
2028+ const existing = get().draftsByThreadKey[threadKey];
2029+ if (existing) {
2030+ for (const image of existing.images) {
2031+ revokeObjectPreviewUrl(image.previewUrl);
2032+ }
16162033 }
1617− return { draftsByThreadId: nextDraftsByThreadId };
1618− });
1619− },
1620− setModelSelection: (threadId, modelSelection) => {
1621− if (threadId.length === 0) {
1622− return;
1623− }
1624− const normalized = normalizeModelSelection(modelSelection);
1625− set((state) => {
1626− const existing = state.draftsByThreadId[threadId];
1627− if (!existing && normalized === null) {
1628− return state;
2034+ set((state) => {
2035+ const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined;
2036+ const hasLogicalProjectMapping = Object.values(
2037+ state.logicalProjectDraftThreadKeyByLogicalProjectKey,
2038+ ).includes(threadKey);
2039+ const hasComposerDraft = state.draftsByThreadKey[threadKey] !== undefined;
2040+ if (!hasDraftThread && !hasLogicalProjectMapping && !hasComposerDraft) {
2041+ return state;
2042+ }
2043+ return removeDraftThreadReferences(state, threadKey);
2044+ });
2045+ },
2046+ setStickyModelSelection: (modelSelection) => {
2047+ const normalized = normalizeModelSelection(modelSelection);
2048+ set((state) => {
2049+ if (!normalized) {
2050+ return state;
2051+ }
2052+ const nextMap: Partial<Record<ProviderKind, ModelSelection>> = {
2053+ ...state.stickyModelSelectionByProvider,
2054+ [normalized.provider]: normalized,
2055+ };
2056+ if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {
2057+ return state.stickyActiveProvider === normalized.provider
2058+ ? state
2059+ : { stickyActiveProvider: normalized.provider };
2060+ }
2061+ return {
2062+ stickyModelSelectionByProvider: nextMap,
2063+ stickyActiveProvider: normalized.provider,
2064+ };
2065+ });
2066+ },
2067+ applyStickyState: (threadRef) => {
2068+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2069+ if (threadKey.length === 0) {
2070+ return;
16292071 }
1630− const base = existing ?? createEmptyThreadDraft();
1631− const nextMap = { ...base.modelSelectionByProvider };
1632− if (normalized) {
1633− const current = nextMap[normalized.provider];
1634− if (normalized.options !== undefined) {
1635− // Explicit options provided → use them
1636− nextMap[normalized.provider] = normalized;
2072+ set((state) => {
2073+ const stickyMap = state.stickyModelSelectionByProvider;
2074+ const stickyActiveProvider = state.stickyActiveProvider;
2075+ if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) {
2076+ return state;
2077+ }
2078+ const existing = state.draftsByThreadKey[threadKey];
2079+ const base = existing ?? createEmptyThreadDraft();
2080+ const nextMap = { ...base.modelSelectionByProvider };
2081+ for (const [provider, selection] of Object.entries(stickyMap)) {
2082+ if (selection) {
2083+ const current = nextMap[provider as ProviderKind];
2084+ nextMap[provider as ProviderKind] = {
2085+ ...selection,
2086+ model: current?.model ?? selection.model,
2087+ };
2088+ }
2089+ }
2090+ if (
2091+ Equal.equals(base.modelSelectionByProvider, nextMap) &&
2092+ base.activeProvider === stickyActiveProvider
2093+ ) {
2094+ return state;
2095+ }
2096+ const nextDraft: ComposerThreadDraftState = {
2097+ ...base,
2098+ modelSelectionByProvider: nextMap,
2099+ activeProvider: stickyActiveProvider,
2100+ };
2101+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2102+ if (shouldRemoveDraft(nextDraft)) {
2103+ delete nextDraftsByThreadKey[threadKey];
16372104 } else {
1638− // No options in selection → preserve existing options, update provider+model
1639− nextMap[normalized.provider] = {
1640− provider: normalized.provider,
1641− model: normalized.model,
1642− ...(current?.options ? { options: current.options } : {}),
1643− };
2105+ nextDraftsByThreadKey[threadKey] = nextDraft;
16442106 }
2107+ return { draftsByThreadKey: nextDraftsByThreadKey };
2108+ });
2109+ },
2110+ setPrompt: (threadRef, prompt) => {
2111+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2112+ if (threadKey.length === 0) {
2113+ return;
16452114 }
1646− const nextActiveProvider = normalized?.provider ?? base.activeProvider;
1647− if (
1648− Equal.equals(base.modelSelectionByProvider, nextMap) &&
1649− base.activeProvider === nextActiveProvider
1650− ) {
1651− return state;
1652− }
1653− const nextDraft: ComposerThreadDraftState = {
1654− ...base,
1655− modelSelectionByProvider: nextMap,
1656− activeProvider: nextActiveProvider,
1657− };
1658− const nextDraftsByThreadId = { ...state.draftsByThreadId };
1659− if (shouldRemoveDraft(nextDraft)) {
1660− delete nextDraftsByThreadId[threadId];
1661− } else {
1662− nextDraftsByThreadId[threadId] = nextDraft;
1663− }
1664− return { draftsByThreadId: nextDraftsByThreadId };
1665− });
1666− },
1667− setModelOptions: (threadId, modelOptions) => {
1668− if (threadId.length === 0) {
1669− return;
1670− }
1671− const normalizedOpts = normalizeProviderModelOptions(modelOptions);
1672− set((state) => {
1673− const existing = state.draftsByThreadId[threadId];
1674− if (!existing && normalizedOpts === null) {
1675− return state;
1676− }
1677− const base = existing ?? createEmptyThreadDraft();
1678− const nextMap = { ...base.modelSelectionByProvider };
1679− for (const provider of ["codex", "claudeAgent"] as const) {
1680− // Only touch providers explicitly present in the input
1681− if (!normalizedOpts || !(provider in normalizedOpts)) continue;
1682− const opts = normalizedOpts[provider];
1683− const current = nextMap[provider];
1684− if (opts) {
1685− nextMap[provider] = {
1686− provider,
1687− model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
1688− options: opts,
1689− };
1690− } else if (current?.options) {
1691− // Remove options but keep the selection
1692− const { options: _, ...rest } = current;
1693− nextMap[provider] = rest as ModelSelection;
2115+ set((state) => {
2116+ const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2117+ const nextDraft: ComposerThreadDraftState = {
2118+ ...existing,
2119+ prompt,
2120+ };
2121+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2122+ if (shouldRemoveDraft(nextDraft)) {
2123+ delete nextDraftsByThreadKey[threadKey];
2124+ } else {
2125+ nextDraftsByThreadKey[threadKey] = nextDraft;
16942126 }
2127+ return { draftsByThreadKey: nextDraftsByThreadKey };
2128+ });
2129+ },
2130+ setTerminalContexts: (threadRef, contexts) => {
2131+ const threadKey = resolveComposerDraftKey(get(), threadRef);
2132+ const threadId = resolveComposerThreadId(get(), threadRef);
2133+ if (!threadKey || !threadId) {
2134+ return;
16952135 }
1696− if (Equal.equals(base.modelSelectionByProvider, nextMap)) {
1697− return state;
2136+ const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts);
2137+ set((state) => {
2138+ const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2139+ const nextDraft: ComposerThreadDraftState = {
2140+ ...existing,
2141+ prompt: ensureInlineTerminalContextPlaceholders(
2142+ existing.prompt,
2143+ normalizedContexts.length,
2144+ ),
2145+ terminalContexts: normalizedContexts,
2146+ };
2147+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2148+ if (shouldRemoveDraft(nextDraft)) {
2149+ delete nextDraftsByThreadKey[threadKey];
2150+ } else {
2151+ nextDraftsByThreadKey[threadKey] = nextDraft;
2152+ }
2153+ return { draftsByThreadKey: nextDraftsByThreadKey };
2154+ });
2155+ },
2156+ setModelSelection: (threadRef, modelSelection) => {
2157+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2158+ if (threadKey.length === 0) {
2159+ return;
16982160 }
1699− const nextDraft: ComposerThreadDraftState = {
1700− ...base,
1701− modelSelectionByProvider: nextMap,
1702− };
1703− const nextDraftsByThreadId = { ...state.draftsByThreadId };
1704− if (shouldRemoveDraft(nextDraft)) {
1705− delete nextDraftsByThreadId[threadId];
1706− } else {
1707− nextDraftsByThreadId[threadId] = nextDraft;
2161+ const normalized = normalizeModelSelection(modelSelection);
2162+ set((state) => {
2163+ const existing = state.draftsByThreadKey[threadKey];
2164+ if (!existing && normalized === null) {
2165+ return state;
2166+ }
2167+ const base = existing ?? createEmptyThreadDraft();
2168+ const nextMap = { ...base.modelSelectionByProvider };
2169+ if (normalized) {
2170+ const current = nextMap[normalized.provider];
2171+ if (normalized.options !== undefined) {
2172+ // Explicit options provided → use them
2173+ nextMap[normalized.provider] = normalized;
2174+ } else {
2175+ // No options in selection → preserve existing options, update provider+model
2176+ nextMap[normalized.provider] = {
2177+ provider: normalized.provider,
2178+ model: normalized.model,
2179+ ...(current?.options ? { options: current.options } : {}),
2180+ };
2181+ }
2182+ }
2183+ const nextActiveProvider = normalized?.provider ?? base.activeProvider;
2184+ if (
2185+ Equal.equals(base.modelSelectionByProvider, nextMap) &&
2186+ base.activeProvider === nextActiveProvider
2187+ ) {
2188+ return state;
2189+ }
2190+ const nextDraft: ComposerThreadDraftState = {
2191+ ...base,
2192+ modelSelectionByProvider: nextMap,
2193+ activeProvider: nextActiveProvider,
2194+ };
2195+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2196+ if (shouldRemoveDraft(nextDraft)) {
2197+ delete nextDraftsByThreadKey[threadKey];
2198+ } else {
2199+ nextDraftsByThreadKey[threadKey] = nextDraft;
2200+ }
2201+ return { draftsByThreadKey: nextDraftsByThreadKey };
2202+ });
2203+ },
2204+ setModelOptions: (threadRef, modelOptions) => {
2205+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2206+ if (threadKey.length === 0) {
2207+ return;
17082208 }
1709− return { draftsByThreadId: nextDraftsByThreadId };
1710− });
1711− },
1712− setProviderModelOptions: (threadId, provider, nextProviderOptions, options) => {
1713− if (threadId.length === 0) {
1714− return;
1715− }
1716− const normalizedProvider = normalizeProviderKind(provider);
1717− if (normalizedProvider === null) {
1718− return;
1719− }
1720− // Normalize just this provider's options
1721− const normalizedOpts = normalizeProviderModelOptions(
1722− { [normalizedProvider]: nextProviderOptions },
1723− normalizedProvider,
1724− );
1725− const providerOpts = normalizedOpts?.[normalizedProvider];
1726−
1727− set((state) => {
1728− const existing = state.draftsByThreadId[threadId];
1729− const base = existing ?? createEmptyThreadDraft();
1730−
1731− // Update the map entry for this provider
1732− const nextMap = { ...base.modelSelectionByProvider };
1733− const currentForProvider = nextMap[normalizedProvider];
1734− if (providerOpts) {
1735− nextMap[normalizedProvider] = {
1736− provider: normalizedProvider,
1737− model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
1738− options: providerOpts,
2209+ const normalizedOpts = normalizeProviderModelOptions(modelOptions);
2210+ set((state) => {
2211+ const existing = state.draftsByThreadKey[threadKey];
2212+ if (!existing && normalizedOpts === null) {
2213+ return state;
2214+ }
2215+ const base = existing ?? createEmptyThreadDraft();
2216+ const nextMap = { ...base.modelSelectionByProvider };
2217+ for (const provider of ["codex", "claudeAgent"] as const) {
2218+ // Only touch providers explicitly present in the input
2219+ if (!normalizedOpts || !(provider in normalizedOpts)) continue;
2220+ const opts = normalizedOpts[provider];
2221+ const current = nextMap[provider];
2222+ if (opts) {
2223+ nextMap[provider] = {
2224+ provider,
2225+ model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
2226+ options: opts,
2227+ };
2228+ } else if (current?.options) {
2229+ // Remove options but keep the selection
2230+ const { options: _, ...rest } = current;
2231+ nextMap[provider] = rest as ModelSelection;
2232+ }
2233+ }
2234+ if (Equal.equals(base.modelSelectionByProvider, nextMap)) {
2235+ return state;
2236+ }
2237+ const nextDraft: ComposerThreadDraftState = {
2238+ ...base,
2239+ modelSelectionByProvider: nextMap,
17392240 };
1740− } else if (currentForProvider?.options) {
1741− const { options: _, ...rest } = currentForProvider;
1742− nextMap[normalizedProvider] = rest as ModelSelection;
2241+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2242+ if (shouldRemoveDraft(nextDraft)) {
2243+ delete nextDraftsByThreadKey[threadKey];
2244+ } else {
2245+ nextDraftsByThreadKey[threadKey] = nextDraft;
2246+ }
2247+ return { draftsByThreadKey: nextDraftsByThreadKey };
2248+ });
2249+ },
2250+ setProviderModelOptions: (threadRef, provider, nextProviderOptions, options) => {
2251+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2252+ if (threadKey.length === 0) {
2253+ return;
17432254 }
2255+ const normalizedProvider = normalizeProviderKind(provider);
2256+ if (normalizedProvider === null) {
2257+ return;
2258+ }
2259+ // Normalize just this provider's options
2260+ const normalizedOpts = normalizeProviderModelOptions(
2261+ { [normalizedProvider]: nextProviderOptions },
2262+ normalizedProvider,
2263+ );
2264+ const providerOpts = normalizedOpts?.[normalizedProvider];
17442265
1745− // Handle sticky persistence
1746− let nextStickyMap = state.stickyModelSelectionByProvider;
1747− let nextStickyActiveProvider = state.stickyActiveProvider;
1748− if (options?.persistSticky === true) {
1749− nextStickyMap = { ...state.stickyModelSelectionByProvider };
1750− const stickyBase =
1751− nextStickyMap[normalizedProvider] ??
1752− base.modelSelectionByProvider[normalizedProvider] ??
1753− ({
1754− provider: normalizedProvider,
1755− model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
1756− } as ModelSelection);
2266+ set((state) => {
2267+ const existing = state.draftsByThreadKey[threadKey];
2268+ const base = existing ?? createEmptyThreadDraft();
2269+
2270+ // Update the map entry for this provider
2271+ const nextMap = { ...base.modelSelectionByProvider };
2272+ const currentForProvider = nextMap[normalizedProvider];
17572273 if (providerOpts) {
1758− nextStickyMap[normalizedProvider] = {
1759− ...stickyBase,
2274+ nextMap[normalizedProvider] = {
17602275 provider: normalizedProvider,
2276+ model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
17612277 options: providerOpts,
17622278 };
1763− } else if (stickyBase.options) {
1764− const { options: _, ...rest } = stickyBase;
1765− nextStickyMap[normalizedProvider] = rest as ModelSelection;
2279+ } else if (currentForProvider?.options) {
2280+ const { options: _, ...rest } = currentForProvider;
2281+ nextMap[normalizedProvider] = rest as ModelSelection;
17662282 }
1767− nextStickyActiveProvider = base.activeProvider ?? normalizedProvider;
1768− }
17692283
1770− if (
1771− Equal.equals(base.modelSelectionByProvider, nextMap) &&
1772− Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) &&
1773− state.stickyActiveProvider === nextStickyActiveProvider
1774− ) {
1775− return state;
1776− }
2284+ // Handle sticky persistence
2285+ let nextStickyMap = state.stickyModelSelectionByProvider;
2286+ let nextStickyActiveProvider = state.stickyActiveProvider;
2287+ if (options?.persistSticky === true) {
2288+ nextStickyMap = { ...state.stickyModelSelectionByProvider };
2289+ const stickyBase =
2290+ nextStickyMap[normalizedProvider] ??
2291+ base.modelSelectionByProvider[normalizedProvider] ??
2292+ ({
2293+ provider: normalizedProvider,
2294+ model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
2295+ } as ModelSelection);
2296+ if (providerOpts) {
2297+ nextStickyMap[normalizedProvider] = {
2298+ ...stickyBase,
2299+ provider: normalizedProvider,
2300+ options: providerOpts,
2301+ };
2302+ } else if (stickyBase.options) {
2303+ const { options: _, ...rest } = stickyBase;
2304+ nextStickyMap[normalizedProvider] = rest as ModelSelection;
2305+ }
2306+ nextStickyActiveProvider = base.activeProvider ?? normalizedProvider;
2307+ }
17772308
1778− const nextDraft: ComposerThreadDraftState = {
1779− ...base,
1780− modelSelectionByProvider: nextMap,
1781− };
1782− const nextDraftsByThreadId = { ...state.draftsByThreadId };
1783− if (shouldRemoveDraft(nextDraft)) {
1784− delete nextDraftsByThreadId[threadId];
1785− } else {
1786− nextDraftsByThreadId[threadId] = nextDraft;
1787− }
2309+ if (
2310+ Equal.equals(base.modelSelectionByProvider, nextMap) &&
2311+ Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) &&
2312+ state.stickyActiveProvider === nextStickyActiveProvider
2313+ ) {
2314+ return state;
2315+ }
17882316
1789− return {
1790− draftsByThreadId: nextDraftsByThreadId,
1791− ...(options?.persistSticky === true
1792− ? {
1793− stickyModelSelectionByProvider: nextStickyMap,
1794− stickyActiveProvider: nextStickyActiveProvider,
1795− }
1796− : {}),
1797− };
1798− });
1799− },
1800− setRuntimeMode: (threadId, runtimeMode) => {
1801− if (threadId.length === 0) {
1802− return;
1803− }
1804− const nextRuntimeMode =
1805− runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null;
1806− set((state) => {
1807− const existing = state.draftsByThreadId[threadId];
1808− if (!existing && nextRuntimeMode === null) {
1809− return state;
1810− }
1811− const base = existing ?? createEmptyThreadDraft();
1812− if (base.runtimeMode === nextRuntimeMode) {
1813− return state;
1814− }
1815− const nextDraft: ComposerThreadDraftState = {
1816− ...base,
1817− runtimeMode: nextRuntimeMode,
1818− };
1819− const nextDraftsByThreadId = { ...state.draftsByThreadId };
1820− if (shouldRemoveDraft(nextDraft)) {
1821− delete nextDraftsByThreadId[threadId];
1822− } else {
1823− nextDraftsByThreadId[threadId] = nextDraft;
1824− }
1825− return { draftsByThreadId: nextDraftsByThreadId };
1826− });
1827− },
1828− setInteractionMode: (threadId, interactionMode) => {
1829− if (threadId.length === 0) {
1830− return;
1831− }
1832− const nextInteractionMode =
1833− interactionMode === "plan" || interactionMode === "default" ? interactionMode : null;
1834− set((state) => {
1835− const existing = state.draftsByThreadId[threadId];
1836− if (!existing && nextInteractionMode === null) {
1837− return state;
2317+ const nextDraft: ComposerThreadDraftState = {
2318+ ...base,
2319+ modelSelectionByProvider: nextMap,
2320+ };
2321+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2322+ if (shouldRemoveDraft(nextDraft)) {
2323+ delete nextDraftsByThreadKey[threadKey];
2324+ } else {
2325+ nextDraftsByThreadKey[threadKey] = nextDraft;
2326+ }
2327+
2328+ return {
2329+ draftsByThreadKey: nextDraftsByThreadKey,
2330+ ...(options?.persistSticky === true
2331+ ? {
2332+ stickyModelSelectionByProvider: nextStickyMap,
2333+ stickyActiveProvider: nextStickyActiveProvider,
2334+ }
2335+ : {}),
2336+ };
2337+ });
2338+ },
2339+ setRuntimeMode: (threadRef, runtimeMode) => {
2340+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2341+ if (threadKey.length === 0) {
2342+ return;
18382343 }
1839− const base = existing ?? createEmptyThreadDraft();
1840− if (base.interactionMode === nextInteractionMode) {
1841− return state;
2344+ const nextRuntimeMode =
2345+ runtimeMode === "approval-required" || runtimeMode === "full-access"
2346+ ? runtimeMode
2347+ : null;
2348+ set((state) => {
2349+ const existing = state.draftsByThreadKey[threadKey];
2350+ if (!existing && nextRuntimeMode === null) {
2351+ return state;
2352+ }
2353+ const base = existing ?? createEmptyThreadDraft();
2354+ if (base.runtimeMode === nextRuntimeMode) {
2355+ return state;
2356+ }
2357+ const nextDraft: ComposerThreadDraftState = {
2358+ ...base,
2359+ runtimeMode: nextRuntimeMode,
2360+ };
2361+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2362+ if (shouldRemoveDraft(nextDraft)) {
2363+ delete nextDraftsByThreadKey[threadKey];
2364+ } else {
2365+ nextDraftsByThreadKey[threadKey] = nextDraft;
2366+ }
2367+ return { draftsByThreadKey: nextDraftsByThreadKey };
2368+ });
2369+ },
2370+ setInteractionMode: (threadRef, interactionMode) => {
2371+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2372+ if (threadKey.length === 0) {
2373+ return;
18422374 }
1843− const nextDraft: ComposerThreadDraftState = {
1844− ...base,
1845− interactionMode: nextInteractionMode,
1846− };
1847− const nextDraftsByThreadId = { ...state.draftsByThreadId };
1848− if (shouldRemoveDraft(nextDraft)) {
1849− delete nextDraftsByThreadId[threadId];
1850− } else {
1851− nextDraftsByThreadId[threadId] = nextDraft;
2375+ const nextInteractionMode =
2376+ interactionMode === "plan" || interactionMode === "default" ? interactionMode : null;
2377+ set((state) => {
2378+ const existing = state.draftsByThreadKey[threadKey];
2379+ if (!existing && nextInteractionMode === null) {
2380+ return state;
2381+ }
2382+ const base = existing ?? createEmptyThreadDraft();
2383+ if (base.interactionMode === nextInteractionMode) {
2384+ return state;
2385+ }
2386+ const nextDraft: ComposerThreadDraftState = {
2387+ ...base,
2388+ interactionMode: nextInteractionMode,
2389+ };
2390+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2391+ if (shouldRemoveDraft(nextDraft)) {
2392+ delete nextDraftsByThreadKey[threadKey];
2393+ } else {
2394+ nextDraftsByThreadKey[threadKey] = nextDraft;
2395+ }
2396+ return { draftsByThreadKey: nextDraftsByThreadKey };
2397+ });
2398+ },
2399+ addImage: (threadRef, image) => {
2400+ const threadKey = resolveComposerDraftKey(get(), threadRef);
2401+ const threadId = resolveComposerThreadId(get(), threadRef);
2402+ if (!threadKey || !threadId) {
2403+ return;
18522404 }
1853− return { draftsByThreadId: nextDraftsByThreadId };
1854− });
1855− },
1856− addImage: (threadId, image) => {
1857− if (threadId.length === 0) {
1858− return;
1859− }
1860− get().addImages(threadId, [image]);
1861− },
1862− addImages: (threadId, images) => {
1863− if (threadId.length === 0 || images.length === 0) {
1864− return;
1865− }
1866− set((state) => {
1867− const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1868− const existingIds = new Set(existing.images.map((image) => image.id));
1869− const existingDedupKeys = new Set(
1870− existing.images.map((image) => composerImageDedupKey(image)),
2405+ get().addImages(
2406+ typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef,
2407+ [image],
18712408 );
1872− const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl));
1873− const dedupedIncoming: ComposerImageAttachment[] = [];
1874− for (const image of images) {
1875− const dedupKey = composerImageDedupKey(image);
1876− if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) {
1877− // Avoid revoking a blob URL that's still referenced by an accepted image.
1878− if (!acceptedPreviewUrls.has(image.previewUrl)) {
1879− revokeObjectPreviewUrl(image.previewUrl);
2409+ },
2410+ addImages: (threadRef, images) => {
2411+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2412+ if (threadKey.length === 0 || images.length === 0) {
2413+ return;
2414+ }
2415+ set((state) => {
2416+ const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2417+ const existingIds = new Set(existing.images.map((image) => image.id));
2418+ const existingDedupKeys = new Set(
2419+ existing.images.map((image) => composerImageDedupKey(image)),
2420+ );
2421+ const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl));
2422+ const dedupedIncoming: ComposerImageAttachment[] = [];
2423+ for (const image of images) {
2424+ const dedupKey = composerImageDedupKey(image);
2425+ if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) {
2426+ // Avoid revoking a blob URL that's still referenced by an accepted image.
2427+ if (!acceptedPreviewUrls.has(image.previewUrl)) {
2428+ revokeObjectPreviewUrl(image.previewUrl);
2429+ }
2430+ continue;
18802431 }
1881− continue;
2432+ dedupedIncoming.push(image);
2433+ existingIds.add(image.id);
2434+ existingDedupKeys.add(dedupKey);
2435+ acceptedPreviewUrls.add(image.previewUrl);
18822436 }
1883− dedupedIncoming.push(image);
1884− existingIds.add(image.id);
1885− existingDedupKeys.add(dedupKey);
1886− acceptedPreviewUrls.add(image.previewUrl);
1887− }
1888− if (dedupedIncoming.length === 0) {
1889− return state;
1890− }
1891− return {
1892− draftsByThreadId: {
1893− ...state.draftsByThreadId,
1894− [threadId]: {
1895− ...existing,
1896− images: [...existing.images, ...dedupedIncoming],
2437+ if (dedupedIncoming.length === 0) {
2438+ return state;
2439+ }
2440+ return {
2441+ draftsByThreadKey: {
2442+ ...state.draftsByThreadKey,
2443+ [threadKey]: {
2444+ ...existing,
2445+ images: [...existing.images, ...dedupedIncoming],
2446+ },
18972447 },
1898− },
1899− };
1900− });
1901− },
1902− removeImage: (threadId, imageId) => {
1903− if (threadId.length === 0) {
1904− return;
1905− }
1906− const existing = get().draftsByThreadId[threadId];
1907− if (!existing) {
1908− return;
1909− }
1910− const removedImage = existing.images.find((image) => image.id === imageId);
1911− if (removedImage) {
1912− revokeObjectPreviewUrl(removedImage.previewUrl);
1913− }
1914− set((state) => {
1915− const current = state.draftsByThreadId[threadId];
1916− if (!current) {
1917− return state;
1918− }
1919− const nextDraft: ComposerThreadDraftState = {
1920− ...current,
1921− images: current.images.filter((image) => image.id !== imageId),
1922− nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId),
1923− persistedAttachments: current.persistedAttachments.filter(
1924− (attachment) => attachment.id !== imageId,
1925− ),
1926− };
1927− const nextDraftsByThreadId = { ...state.draftsByThreadId };
1928− if (shouldRemoveDraft(nextDraft)) {
1929− delete nextDraftsByThreadId[threadId];
1930− } else {
1931− nextDraftsByThreadId[threadId] = nextDraft;
2448+ };
2449+ });
2450+ },
2451+ removeImage: (threadRef, imageId) => {
2452+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2453+ if (threadKey.length === 0) {
2454+ return;
19322455 }
1933− return { draftsByThreadId: nextDraftsByThreadId };
1934− });
1935− },
1936− insertTerminalContext: (threadId, prompt, context, index) => {
1937− if (threadId.length === 0) {
1938− return false;
1939− }
1940− let inserted = false;
1941− set((state) => {
1942− const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1943− const normalizedContext = normalizeTerminalContextForThread(threadId, context);
1944− if (!normalizedContext) {
1945− return state;
2456+ const existing = get().draftsByThreadKey[threadKey];
2457+ if (!existing) {
2458+ return;
19462459 }
1947− const dedupKey = terminalContextDedupKey(normalizedContext);
1948− if (
1949− existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) ||
1950− existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey)
1951− ) {
1952− return state;
2460+ const removedImage = existing.images.find((image) => image.id === imageId);
2461+ if (removedImage) {
2462+ revokeObjectPreviewUrl(removedImage.previewUrl);
19532463 }
1954− inserted = true;
1955− const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index));
1956− const nextDraft: ComposerThreadDraftState = {
1957− ...existing,
1958− prompt,
1959− terminalContexts: [
1960− ...existing.terminalContexts.slice(0, boundedIndex),
1961− normalizedContext,
1962− ...existing.terminalContexts.slice(boundedIndex),
1963− ],
1964− };
1965− return {
1966− draftsByThreadId: {
1967− ...state.draftsByThreadId,
1968− [threadId]: nextDraft,
1969− },
1970− };
1971− });
1972− return inserted;
1973− },
1974− addTerminalContext: (threadId, context) => {
1975− if (threadId.length === 0) {
1976− return;
1977− }
1978− get().addTerminalContexts(threadId, [context]);
1979− },
1980− addTerminalContexts: (threadId, contexts) => {
1981− if (threadId.length === 0 || contexts.length === 0) {
1982− return;
1983− }
1984− set((state) => {
1985− const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1986− const acceptedContexts = normalizeTerminalContextsForThread(threadId, [
1987− ...existing.terminalContexts,
1988− ...contexts,
1989− ]).slice(existing.terminalContexts.length);
1990− if (acceptedContexts.length === 0) {
1991− return state;
2464+ set((state) => {
2465+ const current = state.draftsByThreadKey[threadKey];
2466+ if (!current) {
2467+ return state;
2468+ }
2469+ const nextDraft: ComposerThreadDraftState = {
2470+ ...current,
2471+ images: current.images.filter((image) => image.id !== imageId),
2472+ nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId),
2473+ persistedAttachments: current.persistedAttachments.filter(
2474+ (attachment) => attachment.id !== imageId,
2475+ ),
2476+ };
2477+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2478+ if (shouldRemoveDraft(nextDraft)) {
2479+ delete nextDraftsByThreadKey[threadKey];
2480+ } else {
2481+ nextDraftsByThreadKey[threadKey] = nextDraft;
2482+ }
2483+ return { draftsByThreadKey: nextDraftsByThreadKey };
2484+ });
2485+ },
2486+ insertTerminalContext: (threadRef, prompt, context, index) => {
2487+ const threadKey = resolveComposerDraftKey(get(), threadRef);
2488+ const threadId = resolveComposerThreadId(get(), threadRef);
2489+ if (!threadKey || !threadId) {
2490+ return false;
19922491 }
1993− return {
1994− draftsByThreadId: {
1995− ...state.draftsByThreadId,
1996− [threadId]: {
1997− ...existing,
1998− prompt: ensureInlineTerminalContextPlaceholders(
1999− existing.prompt,
2000− existing.terminalContexts.length + acceptedContexts.length,
2001− ),
2002− terminalContexts: [...existing.terminalContexts, ...acceptedContexts],
2492+ let inserted = false;
2493+ set((state) => {
2494+ const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2495+ const normalizedContext = normalizeTerminalContextForThread(threadId, context);
2496+ if (!normalizedContext) {
2497+ return state;
2498+ }
2499+ const dedupKey = terminalContextDedupKey(normalizedContext);
2500+ if (
2501+ existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) ||
2502+ existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey)
2503+ ) {
2504+ return state;
2505+ }
2506+ inserted = true;
2507+ const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index));
2508+ const nextDraft: ComposerThreadDraftState = {
2509+ ...existing,
2510+ prompt,
2511+ terminalContexts: [
2512+ ...existing.terminalContexts.slice(0, boundedIndex),
2513+ normalizedContext,
2514+ ...existing.terminalContexts.slice(boundedIndex),
2515+ ],
2516+ };
2517+ return {
2518+ draftsByThreadKey: {
2519+ ...state.draftsByThreadKey,
2520+ [threadKey]: nextDraft,
20032521 },
2004− },
2005− };
2006− });
2007− },
2008− removeTerminalContext: (threadId, contextId) => {
2009− if (threadId.length === 0 || contextId.length === 0) {
2010− return;
2011− }
2012− set((state) => {
2013− const current = state.draftsByThreadId[threadId];
2014− if (!current) {
2015− return state;
2016− }
2017− const nextDraft: ComposerThreadDraftState = {
2018− ...current,
2019− terminalContexts: current.terminalContexts.filter(
2020− (context) => context.id !== contextId,
2021− ),
2022− };
2023− const nextDraftsByThreadId = { ...state.draftsByThreadId };
2024− if (shouldRemoveDraft(nextDraft)) {
2025− delete nextDraftsByThreadId[threadId];
2026− } else {
2027− nextDraftsByThreadId[threadId] = nextDraft;
2028− }
2029− return { draftsByThreadId: nextDraftsByThreadId };
2030− });
2031− },
2032− clearTerminalContexts: (threadId) => {
2033− if (threadId.length === 0) {
2034− return;
2035− }
2036− set((state) => {
2037− const current = state.draftsByThreadId[threadId];
2038− if (!current || current.terminalContexts.length === 0) {
2039− return state;
2040− }
2041− const nextDraft: ComposerThreadDraftState = {
2042− ...current,
2043− terminalContexts: [],
2044− };
2045− const nextDraftsByThreadId = { ...state.draftsByThreadId };
2046− if (shouldRemoveDraft(nextDraft)) {
2047− delete nextDraftsByThreadId[threadId];
2048− } else {
2049− nextDraftsByThreadId[threadId] = nextDraft;
2522+ };
2523+ });
2524+ return inserted;
2525+ },
2526+ addTerminalContext: (threadRef, context) => {
2527+ const threadKey = resolveComposerDraftKey(get(), threadRef);
2528+ const threadId = resolveComposerThreadId(get(), threadRef);
2529+ if (!threadKey || !threadId) {
2530+ return;
20502531 }
2051− return { draftsByThreadId: nextDraftsByThreadId };
2052− });
2053− },
2054− clearPersistedAttachments: (threadId) => {
2055− if (threadId.length === 0) {
2056− return;
2057− }
2058− set((state) => {
2059− const current = state.draftsByThreadId[threadId];
2060− if (!current) {
2061− return state;
2532+ get().addTerminalContexts(
2533+ typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef,
2534+ [context],
2535+ );
2536+ },
2537+ addTerminalContexts: (threadRef, contexts) => {
2538+ const threadKey = resolveComposerDraftKey(get(), threadRef);
2539+ const threadId = resolveComposerThreadId(get(), threadRef);
2540+ if (!threadKey || !threadId || contexts.length === 0) {
2541+ return;
20622542 }
2063− const nextDraft: ComposerThreadDraftState = {
2064− ...current,
2065− persistedAttachments: [],
2066− nonPersistedImageIds: [],
2067− };
2068− const nextDraftsByThreadId = { ...state.draftsByThreadId };
2069− if (shouldRemoveDraft(nextDraft)) {
2070− delete nextDraftsByThreadId[threadId];
2071− } else {
2072− nextDraftsByThreadId[threadId] = nextDraft;
2543+ set((state) => {
2544+ const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2545+ const acceptedContexts = normalizeTerminalContextsForThread(threadId, [
2546+ ...existing.terminalContexts,
2547+ ...contexts,
2548+ ]).slice(existing.terminalContexts.length);
2549+ if (acceptedContexts.length === 0) {
2550+ return state;
2551+ }
2552+ return {
2553+ draftsByThreadKey: {
2554+ ...state.draftsByThreadKey,
2555+ [threadKey]: {
2556+ ...existing,
2557+ prompt: ensureInlineTerminalContextPlaceholders(
2558+ existing.prompt,
2559+ existing.terminalContexts.length + acceptedContexts.length,
2560+ ),
2561+ terminalContexts: [...existing.terminalContexts, ...acceptedContexts],
2562+ },
2563+ },
2564+ };
2565+ });
2566+ },
2567+ removeTerminalContext: (threadRef, contextId) => {
2568+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2569+ if (threadKey.length === 0 || contextId.length === 0) {
2570+ return;
20732571 }
2074− return { draftsByThreadId: nextDraftsByThreadId };
2075− });
2076− },
2077− syncPersistedAttachments: (threadId, attachments) => {
2078− if (threadId.length === 0) {
2079− return;
2080− }
2081− const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id));
2082− set((state) => {
2083− const current = state.draftsByThreadId[threadId];
2084− if (!current) {
2085− return state;
2572+ set((state) => {
2573+ const current = state.draftsByThreadKey[threadKey];
2574+ if (!current) {
2575+ return state;
2576+ }
2577+ const nextDraft: ComposerThreadDraftState = {
2578+ ...current,
2579+ terminalContexts: current.terminalContexts.filter(
2580+ (context) => context.id !== contextId,
2581+ ),
2582+ };
2583+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2584+ if (shouldRemoveDraft(nextDraft)) {
2585+ delete nextDraftsByThreadKey[threadKey];
2586+ } else {
2587+ nextDraftsByThreadKey[threadKey] = nextDraft;
2588+ }
2589+ return { draftsByThreadKey: nextDraftsByThreadKey };
2590+ });
2591+ },
2592+ clearTerminalContexts: (threadRef) => {
2593+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2594+ if (threadKey.length === 0) {
2595+ return;
20862596 }
2087− const nextDraft: ComposerThreadDraftState = {
2088− ...current,
2089− // Stage attempted attachments so persist middleware can try writing them.
2090− persistedAttachments: attachments,
2091− nonPersistedImageIds: current.nonPersistedImageIds.filter(
2092− (id) => !attachmentIdSet.has(id),
2093− ),
2094− };
2095− const nextDraftsByThreadId = { ...state.draftsByThreadId };
2096− if (shouldRemoveDraft(nextDraft)) {
2097− delete nextDraftsByThreadId[threadId];
2098− } else {
2099− nextDraftsByThreadId[threadId] = nextDraft;
2597+ set((state) => {
2598+ const current = state.draftsByThreadKey[threadKey];
2599+ if (!current || current.terminalContexts.length === 0) {
2600+ return state;
2601+ }
2602+ const nextDraft: ComposerThreadDraftState = {
2603+ ...current,
2604+ terminalContexts: [],
2605+ };
2606+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2607+ if (shouldRemoveDraft(nextDraft)) {
2608+ delete nextDraftsByThreadKey[threadKey];
2609+ } else {
2610+ nextDraftsByThreadKey[threadKey] = nextDraft;
2611+ }
2612+ return { draftsByThreadKey: nextDraftsByThreadKey };
2613+ });
2614+ },
2615+ clearPersistedAttachments: (threadRef) => {
2616+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2617+ if (threadKey.length === 0) {
2618+ return;
21002619 }
2101− return { draftsByThreadId: nextDraftsByThreadId };
2102− });
2103− Promise.resolve().then(() => {
2104− verifyPersistedAttachments(threadId, attachments, set);
2105− });
2106− },
2107− clearComposerContent: (threadId) => {
2108− if (threadId.length === 0) {
2109− return;
2110− }
2111− set((state) => {
2112− const current = state.draftsByThreadId[threadId];
2113− if (!current) {
2114− return state;
2620+ set((state) => {
2621+ const current = state.draftsByThreadKey[threadKey];
2622+ if (!current) {
2623+ return state;
2624+ }
2625+ const nextDraft: ComposerThreadDraftState = {
2626+ ...current,
2627+ persistedAttachments: [],
2628+ nonPersistedImageIds: [],
2629+ };
2630+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2631+ if (shouldRemoveDraft(nextDraft)) {
2632+ delete nextDraftsByThreadKey[threadKey];
2633+ } else {
2634+ nextDraftsByThreadKey[threadKey] = nextDraft;
2635+ }
2636+ return { draftsByThreadKey: nextDraftsByThreadKey };
2637+ });
2638+ },
2639+ syncPersistedAttachments: (threadRef, attachments) => {
2640+ const threadKey = resolveComposerDraftKey(get(), threadRef);
2641+ if (!threadKey) {
2642+ return;
21152643 }
2116− const nextDraft: ComposerThreadDraftState = {
2117− ...current,
2118− prompt: "",
2119− images: [],
2120− nonPersistedImageIds: [],
2121− persistedAttachments: [],
2122− terminalContexts: [],
2123− };
2124− const nextDraftsByThreadId = { ...state.draftsByThreadId };
2125− if (shouldRemoveDraft(nextDraft)) {
2126− delete nextDraftsByThreadId[threadId];
2127− } else {
2128− nextDraftsByThreadId[threadId] = nextDraft;
2644+ const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id));
2645+ set((state) => {
2646+ const current = state.draftsByThreadKey[threadKey];
2647+ if (!current) {
2648+ return state;
2649+ }
2650+ const nextDraft: ComposerThreadDraftState = {
2651+ ...current,
2652+ // Stage attempted attachments so persist middleware can try writing them.
2653+ persistedAttachments: attachments,
2654+ nonPersistedImageIds: current.nonPersistedImageIds.filter(
2655+ (id) => !attachmentIdSet.has(id),
2656+ ),
2657+ };
2658+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2659+ if (shouldRemoveDraft(nextDraft)) {
2660+ delete nextDraftsByThreadKey[threadKey];
2661+ } else {
2662+ nextDraftsByThreadKey[threadKey] = nextDraft;
2663+ }
2664+ return { draftsByThreadKey: nextDraftsByThreadKey };
2665+ });
2666+ Promise.resolve().then(() => {
2667+ verifyPersistedAttachments(threadKey, attachments, set);
2668+ });
2669+ },
2670+ clearComposerContent: (threadRef) => {
2671+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2672+ if (threadKey.length === 0) {
2673+ return;
21292674 }
2130− return { draftsByThreadId: nextDraftsByThreadId };
2131− });
2132− },
2133− }),
2675+ set((state) => {
2676+ const current = state.draftsByThreadKey[threadKey];
2677+ if (!current) {
2678+ return state;
2679+ }
2680+ const nextDraft: ComposerThreadDraftState = {
2681+ ...current,
2682+ prompt: "",
2683+ images: [],
2684+ nonPersistedImageIds: [],
2685+ persistedAttachments: [],
2686+ terminalContexts: [],
2687+ };
2688+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2689+ if (shouldRemoveDraft(nextDraft)) {
2690+ delete nextDraftsByThreadKey[threadKey];
2691+ } else {
2692+ nextDraftsByThreadKey[threadKey] = nextDraft;
2693+ }
2694+ return { draftsByThreadKey: nextDraftsByThreadKey };
2695+ });
2696+ },
2697+ };
2698+ },
21342699 {
21352700 name: COMPOSER_DRAFT_STORAGE_KEY,
21362701 version: COMPOSER_DRAFT_STORAGE_VERSION,
@@ -2140,17 +2705,23 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( · 3 unmodified lines
21402705 merge: (persistedState, currentState) => {
21412706 const normalizedPersisted =
21422707 normalizeCurrentPersistedComposerDraftStoreState(persistedState);
2143− const draftsByThreadId = Object.fromEntries(
2144− Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [
2145− threadId,
2708+ const draftsByThreadKey = Object.fromEntries(
2709+ Object.entries(normalizedPersisted.draftsByThreadKey).map(([threadKey, draft]) => [
2710+ threadKey,
21462711 toHydratedThreadDraft(draft),
21472712 ]),
21482713 );
2714+ const draftThreadsByThreadKey = Object.fromEntries(
2715+ Object.entries(normalizedPersisted.draftThreadsByThreadKey).map(
2716+ ([threadKey, draftThread]) => [threadKey, toHydratedDraftThreadState(draftThread)],
2717+ ),
2718+ ) as Record<string, DraftThreadState>;
21492719 return {
21502720 ...currentState,
2151− draftsByThreadId,
2152− draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId,
2153− projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,
2721+ draftsByThreadKey,
2722+ draftThreadsByThreadKey,
2723+ logicalProjectDraftThreadKeyByLogicalProjectKey:
2724+ normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey,
21542725 stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {},
21552726 stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null,
21562727 };
@@ -2159,19 +2730,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( · 2 unmodified lines
21592730 ),
21602731 );
21612732
2162−export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState {
2163− return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);
2733+export const useComposerDraftStore = composerDraftStore;
2734+
2735+export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState {
2736+ return useComposerDraftStore((state) => {
2737+ return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT;
2738+ });
21642739 }
21652740
21662741 export function useEffectiveComposerModelState(input: {
2167− threadId: ThreadId;
2742+ threadRef?: ComposerThreadTarget;
2743+ draftId?: DraftId;
21682744 providers: ReadonlyArray<ServerProvider>;
21692745 selectedProvider: ProviderKind;
21702746 threadModelSelection: ModelSelection | null | undefined;
21712747 projectModelSelection: ModelSelection | null | undefined;
21722748 settings: UnifiedSettings;
21732749 }): EffectiveComposerModelState {
2174− const draft = useComposerThreadDraft(input.threadId);
2750+ const draft = useComposerThreadDraft(input.threadRef ?? input.draftId ?? DraftId.makeUnsafe(""));
21752751
21762752 return useMemo(
21772753 () =>
@@ -2195,21 +2771,69 @@ export function useEffectiveComposerModelState(input: { · 17 unmodified lines
21952771 }
21962772
21972773 /**
2198− * Clear a draft thread once the server has materialized the same thread id.
2774+ * Mark a draft thread as promoting once the server has materialized the same thread id.
21992775 *
22002776 * Use the single-thread helper for live `thread.created` events and the
22012777 * iterable helper for bootstrap/recovery paths that discover multiple server
22022778 * threads at once.
22032779 */
2204−export function clearPromotedDraftThread(threadId: ThreadId): void {
2205− if (!useComposerDraftStore.getState().getDraftThread(threadId)) {
2780+export function markPromotedDraftThread(threadId: ThreadId): void {
2781+ const store = useComposerDraftStore.getState();
2782+ const draftThreadTargets: ComposerThreadTarget[] = [];
2783+ for (const [draftId, draftThread] of Object.entries(store.draftThreadsByThreadKey)) {
2784+ if (draftThread.threadId === threadId) {
2785+ draftThreadTargets.push(DraftId.makeUnsafe(draftId));
2786+ }
2787+ }
2788+ if (draftThreadTargets.length === 0) {
22062789 return;
22072790 }
2208− useComposerDraftStore.getState().clearDraftThread(threadId);
2791+ for (const draftThreadTarget of draftThreadTargets) {
2792+ store.markDraftThreadPromoting(draftThreadTarget);
2793+ }
2794+}
2795+
2796+export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
2797+ const draftStore = useComposerDraftStore.getState();
2798+ for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {
2799+ if (
2800+ draftThread.environmentId === threadRef.environmentId &&
2801+ draftThread.threadId === threadRef.threadId
2802+ ) {
2803+ draftStore.markDraftThreadPromoting(DraftId.makeUnsafe(draftId), threadRef);
2804+ }
2805+ }
22092806 }
22102807
2211−export function clearPromotedDraftThreads(serverThreadIds: Iterable<ThreadId>): void {
2808+export function markPromotedDraftThreads(serverThreadIds: Iterable<ThreadId>): void {
22122809 for (const threadId of serverThreadIds) {
2213− clearPromotedDraftThread(threadId);
2810+ markPromotedDraftThread(threadId);
2811+ }
2812+}
2813+
2814+export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable<ScopedThreadRef>): void {
2815+ for (const threadRef of serverThreadRefs) {
2816+ markPromotedDraftThreadByRef(threadRef);
2817+ }
2818+}
2819+
2820+export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
2821+ const draftStore = useComposerDraftStore.getState();
2822+ for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {
2823+ if (
2824+ draftThread.promotedTo &&
2825+ draftThread.promotedTo.environmentId === threadRef.environmentId &&
2826+ draftThread.promotedTo.threadId === threadRef.threadId
2827+ ) {
2828+ draftStore.finalizePromotedDraftThread(DraftId.makeUnsafe(draftId));
2829+ }
2830+ }
2831+}
2832+
2833+export function finalizePromotedDraftThreadsByRef(
2834+ serverThreadRefs: Iterable<ScopedThreadRef>,
2835+): void {
2836+ for (const threadRef of serverThreadRefs) {
2837+ finalizePromotedDraftThreadByRef(threadRef);
22142838 }
22152839 }
apps/web/src/editorPreferences.ts+2−2
@@ -1,4 +1,4 @@
1−import { EDITORS, EditorId, NativeApi } from "@t3tools/contracts";
1+import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts";
22 import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage";
33 import { useMemo } from "react";
44
@@ -26,7 +26,7 @@ export function resolveAndPersistPreferredEditor( · 21 unmodified lines
2626 return editor ?? null;
2727 }
2828
29−export async function openInPreferredEditor(api: NativeApi, targetPath: string): Promise<EditorId> {
29+export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise<EditorId> {
3030 const { availableEditors } = await api.server.getConfig();
3131 const editor = resolveAndPersistPreferredEditor(availableEditors);
3232 if (!editor) throw new Error("No available editors found.");
apps/web/src/environmentApi.ts+67−0
@@ -0,0 +1,67 @@
1+import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts";
2+
3+import { readWsRpcClientEntryForEnvironment, WsRpcClient } from "./wsRpcClient";
4+
5+export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
6+ return {
7+ terminal: {
8+ open: (input) => rpcClient.terminal.open(input as never),
9+ write: (input) => rpcClient.terminal.write(input as never),
10+ resize: (input) => rpcClient.terminal.resize(input as never),
11+ clear: (input) => rpcClient.terminal.clear(input as never),
12+ restart: (input) => rpcClient.terminal.restart(input as never),
13+ close: (input) => rpcClient.terminal.close(input as never),
14+ onEvent: (callback) => rpcClient.terminal.onEvent(callback),
15+ },
16+ projects: {
17+ searchEntries: rpcClient.projects.searchEntries,
18+ writeFile: rpcClient.projects.writeFile,
19+ },
20+ git: {
21+ pull: rpcClient.git.pull,
22+ refreshStatus: rpcClient.git.refreshStatus,
23+ onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options),
24+ listBranches: rpcClient.git.listBranches,
25+ createWorktree: rpcClient.git.createWorktree,
26+ removeWorktree: rpcClient.git.removeWorktree,
27+ createBranch: rpcClient.git.createBranch,
28+ checkout: rpcClient.git.checkout,
29+ init: rpcClient.git.init,
30+ resolvePullRequest: rpcClient.git.resolvePullRequest,
31+ preparePullRequestThread: rpcClient.git.preparePullRequestThread,
32+ },
33+ orchestration: {
34+ getSnapshot: rpcClient.orchestration.getSnapshot,
35+ dispatchCommand: rpcClient.orchestration.dispatchCommand,
36+ getTurnDiff: rpcClient.orchestration.getTurnDiff,
37+ getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff,
38+ replayEvents: (fromSequenceExclusive) =>
39+ rpcClient.orchestration
40+ .replayEvents({ fromSequenceExclusive })
41+ .then((events) => [...events]),
42+ onDomainEvent: (callback, options) =>
43+ rpcClient.orchestration.onDomainEvent(callback, options),
44+ },
45+ };
46+}
47+
48+export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi | undefined {
49+ if (typeof window === "undefined") {
50+ return undefined;
51+ }
52+
53+ if (!environmentId) {
54+ return undefined;
55+ }
56+
57+ const entry = readWsRpcClientEntryForEnvironment(environmentId);
58+ return entry ? createEnvironmentApi(entry.client) : undefined;
59+}
60+
61+export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi {
62+ const api = readEnvironmentApi(environmentId);
63+ if (!api) {
64+ throw new Error(`Environment API not found for environment ${environmentId}`);
65+ }
66+ return api;
67+}
apps/web/src/environmentBootstrap.ts+65−0
@@ -0,0 +1,65 @@
1+import {
2+ createKnownEnvironmentFromWsUrl,
3+ getKnownEnvironmentBaseUrl,
4+ type KnownEnvironment,
5+} from "@t3tools/client-runtime";
6+import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts";
7+
8+function createKnownEnvironmentFromDesktopBootstrap(
9+ bootstrap: DesktopEnvironmentBootstrap | null | undefined,
10+): KnownEnvironment | null {
11+ if (!bootstrap?.wsUrl) {
12+ return null;
13+ }
14+
15+ return createKnownEnvironmentFromWsUrl({
16+ id: `desktop:${bootstrap.label}`,
17+ label: bootstrap.label,
18+ source: "desktop-managed",
19+ wsUrl: bootstrap.wsUrl,
20+ });
21+}
22+
23+export function getPrimaryKnownEnvironment(): KnownEnvironment | null {
24+ const desktopEnvironment = createKnownEnvironmentFromDesktopBootstrap(
25+ window.desktopBridge?.getLocalEnvironmentBootstrap(),
26+ );
27+ if (desktopEnvironment) {
28+ return desktopEnvironment;
29+ }
30+
31+ const legacyDesktopWsUrl = window.desktopBridge?.getWsUrl();
32+ if (typeof legacyDesktopWsUrl === "string" && legacyDesktopWsUrl.length > 0) {
33+ return createKnownEnvironmentFromWsUrl({
34+ id: "desktop-legacy",
35+ label: "Local environment",
36+ source: "desktop-managed",
37+ wsUrl: legacyDesktopWsUrl,
38+ });
39+ }
40+
41+ const configuredWsUrl = import.meta.env.VITE_WS_URL;
42+ if (typeof configuredWsUrl === "string" && configuredWsUrl.length > 0) {
43+ return createKnownEnvironmentFromWsUrl({
44+ id: "configured-primary",
45+ label: "Primary environment",
46+ source: "configured",
47+ wsUrl: configuredWsUrl,
48+ });
49+ }
50+
51+ return createKnownEnvironmentFromWsUrl({
52+ id: "window-origin",
53+ label: "Primary environment",
54+ source: "window-origin",
55+ wsUrl: window.location.origin,
56+ });
57+}
58+
59+export function resolvePrimaryEnvironmentBootstrapUrl(): string {
60+ const baseUrl = getKnownEnvironmentBaseUrl(getPrimaryKnownEnvironment());
61+ if (!baseUrl) {
62+ throw new Error("Unable to resolve a known environment bootstrap URL.");
63+ }
64+ return baseUrl;
65+}
apps/web/src/hooks/useHandleNewThread.ts+81−42
@@ -1,5 +1,6 @@
1−import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
2−import { useNavigate, useParams } from "@tanstack/react-router";
1+import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";
2+import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts";
3+import { useParams, useRouter } from "@tanstack/react-router";
34 import { useCallback, useMemo } from "react";
45 import { useShallow } from "zustand/react/shallow";
56 import {
@@ -7,37 +8,49 @@ import { · 1 unmodified line
78 type DraftThreadState,
89 useComposerDraftStore,
910 } from "../composerDraftStore";
10−import { newThreadId } from "../lib/utils";
11+import { newDraftId, newThreadId } from "../lib/utils";
1112 import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
12−import { useStore } from "../store";
13−import { createThreadSelector } from "../storeSelectors";
13+import { deriveLogicalProjectKey } from "../logicalProject";
14+import { selectProjectsAcrossEnvironments, useStore } from "../store";
15+import { createThreadSelectorByRef } from "../storeSelectors";
16+import { resolveThreadRouteTarget } from "../threadRoutes";
1417 import { useUiStateStore } from "../uiStateStore";
1518
1619 export function useHandleNewThread() {
17− const projectIds = useStore(useShallow((store) => store.projectIds));
20+ const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store)));
1821 const projectOrder = useUiStateStore((store) => store.projectOrder);
19− const navigate = useNavigate();
20− const routeThreadId = useParams({
22+ const router = useRouter();
23+ const routeTarget = useParams({
2124 strict: false,
22− select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
25+ select: (params) => resolveThreadRouteTarget(params),
2326 });
27+ const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
2428 const activeThread = useStore(
25− useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]),
29+ useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]),
2630 );
27− const activeDraftThread = useComposerDraftStore((store) =>
28− routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null,
31+ const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
32+ const activeDraftThread = useComposerDraftStore(() =>
33+ routeTarget
34+ ? routeTarget.kind === "server"
35+ ? getDraftThread(routeTarget.threadRef)
36+ : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId)
37+ : null,
2938 );
3039 const orderedProjects = useMemo(() => {
3140 return orderItemsByPreferredIds({
32− items: projectIds,
41+ items: projects,
3342 preferredIds: projectOrder,
34− getId: (projectId) => projectId,
43+ getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
3544 });
36− }, [projectIds, projectOrder]);
45+ }, [projectOrder, projects]);
46+ const getCurrentRouteTarget = useCallback(() => {
47+ const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
48+ return resolveThreadRouteTarget(currentRouteParams);
49+ }, [router]);
3750
3851 const handleNewThread = useCallback(
3952 (
40− projectId: ProjectId,
53+ projectRef: ScopedProjectRef,
4154 options?: {
4255 branch?: string | null;
4356 worktreePath?: string | null;
@@ -45,84 +58,110 @@ export function useHandleNewThread() { · 1 unmodified line
4558 },
4659 ): Promise<void> => {
4760 const {
48− clearProjectDraftThreadId,
61+ getDraftSessionByLogicalProjectKey,
62+ getDraftSession,
4963 getDraftThread,
50− getDraftThreadByProjectId,
5164 applyStickyState,
5265 setDraftThreadContext,
53− setProjectDraftThreadId,
66+ setLogicalProjectDraftThreadId,
5467 } = useComposerDraftStore.getState();
68+ const currentRouteTarget = getCurrentRouteTarget();
69+ const project = projects.find(
70+ (candidate) =>
71+ candidate.id === projectRef.projectId &&
72+ candidate.environmentId === projectRef.environmentId,
73+ );
74+ const logicalProjectKey = project
75+ ? deriveLogicalProjectKey(project)
76+ : scopedProjectKey(projectRef);
5577 const hasBranchOption = options?.branch !== undefined;
5678 const hasWorktreePathOption = options?.worktreePath !== undefined;
5779 const hasEnvModeOption = options?.envMode !== undefined;
58− const storedDraftThread = getDraftThreadByProjectId(projectId);
59− const latestActiveDraftThread: DraftThreadState | null = routeThreadId
60− ? getDraftThread(routeThreadId)
80+ const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey);
81+ const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget
82+ ? currentRouteTarget.kind === "server"
83+ ? getDraftThread(currentRouteTarget.threadRef)
84+ : getDraftSession(currentRouteTarget.draftId)
6185 : null;
6286 if (storedDraftThread) {
6387 return (async () => {
6488 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
65− setDraftThreadContext(storedDraftThread.threadId, {
89+ setDraftThreadContext(storedDraftThread.draftId, {
6690 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
6791 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
6892 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
6993 });
7094 }
71− setProjectDraftThreadId(projectId, storedDraftThread.threadId);
72− if (routeThreadId === storedDraftThread.threadId) {
95+ setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, storedDraftThread.draftId, {
96+ threadId: storedDraftThread.threadId,
97+ });
98+ if (
99+ currentRouteTarget?.kind === "draft" &&
100+ currentRouteTarget.draftId === storedDraftThread.draftId
101+ ) {
73102 return;
74103 }
75− await navigate({
76− to: "/$threadId",
77− params: { threadId: storedDraftThread.threadId },
104+ await router.navigate({
105+ to: "/draft/$draftId",
106+ params: { draftId: storedDraftThread.draftId },
78107 });
79108 })();
80109 }
81110
82− clearProjectDraftThreadId(projectId);
83−
84111 if (
85112 latestActiveDraftThread &&
86− routeThreadId &&
87− latestActiveDraftThread.projectId === projectId
113+ currentRouteTarget?.kind === "draft" &&
114+ latestActiveDraftThread.logicalProjectKey === logicalProjectKey
88115 ) {
89116 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
90− setDraftThreadContext(routeThreadId, {
117+ setDraftThreadContext(currentRouteTarget.draftId, {
91118 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
92119 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
93120 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
94121 });
95122 }
96− setProjectDraftThreadId(projectId, routeThreadId);
123+ setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, {
124+ threadId: latestActiveDraftThread.threadId,
125+ createdAt: latestActiveDraftThread.createdAt,
126+ runtimeMode: latestActiveDraftThread.runtimeMode,
127+ interactionMode: latestActiveDraftThread.interactionMode,
128+ ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
129+ ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
130+ ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
131+ });
97132 return Promise.resolve();
98133 }
99134
135+ const draftId = newDraftId();
100136 const threadId = newThreadId();
101137 const createdAt = new Date().toISOString();
102138 return (async () => {
103− setProjectDraftThreadId(projectId, threadId, {
139+ setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
140+ threadId,
104141 createdAt,
105142 branch: options?.branch ?? null,
106143 worktreePath: options?.worktreePath ?? null,
107144 envMode: options?.envMode ?? "local",
108145 runtimeMode: DEFAULT_RUNTIME_MODE,
109146 });
110− applyStickyState(threadId);
147+ applyStickyState(draftId);
111148
112− await navigate({
113− to: "/$threadId",
114− params: { threadId },
149+ await router.navigate({
150+ to: "/draft/$draftId",
151+ params: { draftId },
115152 });
116153 })();
117154 },
118− [navigate, routeThreadId],
155+ [getCurrentRouteTarget, router, projects],
119156 );
120157
121158 return {
122159 activeDraftThread,
123160 activeThread,
124− defaultProjectId: orderedProjects[0] ?? null,
161+ defaultProjectRef: orderedProjects[0]
162+ ? scopeProjectRef(orderedProjects[0].environmentId, orderedProjects[0].id)
163+ : null,
125164 handleNewThread,
126− routeThreadId,
165+ routeThreadRef,
127166 };
128167 }
apps/web/src/hooks/useSettings.ts+4−6
@@ -26,7 +26,7 @@ import { · 25 unmodified lines
2626 TimestampFormat,
2727 UnifiedSettings,
2828 } from "@t3tools/contracts/settings";
29−import { ensureNativeApi } from "~/nativeApi";
29+import { ensureLocalApi } from "~/localApi";
3030 import { useLocalStorage } from "./useLocalStorage";
3131 import { normalizeCustomModelSlugs } from "~/modelSelection";
3232 import { Predicate, Schema, Struct } from "effect";
@@ -67,9 +67,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): { · 34 unmodified lines
6767 * only re-render when the slice they care about changes.
6868 */
6969
70−export function useSettings<T extends UnifiedSettings = UnifiedSettings>(
71− selector?: (s: UnifiedSettings) => T,
72−): T {
70+export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
7371 const serverSettings = useServerSettings();
7472 const [clientSettings] = useLocalStorage(
7573 CLIENT_SETTINGS_STORAGE_KEY,
@@ -111,7 +109,7 @@ export function useUpdateSettings() { · 35 unmodified lines
111109 applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch));
112110 }
113111 // Fire-and-forget RPC — push will reconcile on success
114− void ensureNativeApi().server.updateSettings(serverPatch);
112+ void ensureLocalApi().server.updateSettings(serverPatch);
115113 }
116114
117115 if (Object.keys(clientPatch).length > 0) {
@@ -239,7 +237,7 @@ export function migrateLocalSettingsToServer(): void { · 121 unmodified lines
239237 // Migrate server-relevant keys via RPC
240238 const serverPatch = buildLegacyServerSettingsMigrationPatch(old);
241239 if (Object.keys(serverPatch).length > 0) {
242− const api = ensureNativeApi();
240+ const api = ensureLocalApi();
243241 void api.server.updateSettings(serverPatch);
244242 }
245243
apps/web/src/hooks/useThreadActions.ts+128−64
@@ -1,42 +1,63 @@
1−import { ThreadId } from "@t3tools/contracts";
2−import { useMutation, useQueryClient } from "@tanstack/react-query";
3−import { useNavigate, useParams } from "@tanstack/react-router";
1+import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2+import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
3+import { useQueryClient } from "@tanstack/react-query";
4+import { useRouter } from "@tanstack/react-router";
45 import { useCallback } from "react";
56
67 import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
78 import { useComposerDraftStore } from "../composerDraftStore";
89 import { useHandleNewThread } from "./useHandleNewThread";
9−import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
10+import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";
11+import { invalidateGitQueries } from "../lib/gitReactQuery";
1012 import { newCommandId } from "../lib/utils";
11−import { readNativeApi } from "../nativeApi";
12−import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store";
13+import { readLocalApi } from "../localApi";
14+import {
15+ selectProjectByRef,
16+ selectThreadByRef,
17+ selectThreadsForEnvironment,
18+ useStore,
19+} from "../store";
1320 import { useTerminalStateStore } from "../terminalStateStore";
21+import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
1422 import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1523 import { toastManager } from "../components/ui/toast";
1624 import { useSettings } from "./useSettings";
1725
1826 export function useThreadActions() {
19− const appSettings = useSettings();
27+ const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder);
28+ const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete);
2029 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
2130 const clearProjectDraftThreadById = useComposerDraftStore(
2231 (store) => store.clearProjectDraftThreadById,
2332 );
2433 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
25− const routeThreadId = useParams({
26− strict: false,
27− select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
28− });
29− const navigate = useNavigate();
34+ const router = useRouter();
3035 const { handleNewThread } = useHandleNewThread();
3136 const queryClient = useQueryClient();
32− const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
37+
38+ const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {
39+ const state = useStore.getState();
40+ const thread = selectThreadByRef(state, target);
41+ if (!thread) {
42+ return null;
43+ }
44+ return {
45+ thread,
46+ threadRef: target,
47+ };
48+ }, []);
49+ const getCurrentRouteThreadRef = useCallback(() => {
50+ const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
51+ return resolveThreadRouteRef(currentRouteParams);
52+ }, [router]);
3353
3454 const archiveThread = useCallback(
35− async (threadId: ThreadId) => {
36− const api = readNativeApi();
55+ async (target: ScopedThreadRef) => {
56+ const api = readEnvironmentApi(target.environmentId);
3757 if (!api) return;
38− const thread = selectThreadById(threadId)(useStore.getState());
39− if (!thread) return;
58+ const resolved = resolveThreadTarget(target);
59+ if (!resolved) return;
60+ const { thread, threadRef } = resolved;
4061 if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
4162 throw new Error("Cannot archive a running thread.");
4263 }
@@ -44,48 +65,69 @@ export function useThreadActions() { · 1 unmodified line
4465 await api.orchestration.dispatchCommand({
4566 type: "thread.archive",
4667 commandId: newCommandId(),
47− threadId,
68+ threadId: threadRef.threadId,
4869 });
70+ const currentRouteThreadRef = getCurrentRouteThreadRef();
4971
50− if (routeThreadId === threadId) {
51− await handleNewThread(thread.projectId);
72+ if (
73+ currentRouteThreadRef?.threadId === threadRef.threadId &&
74+ currentRouteThreadRef.environmentId === threadRef.environmentId
75+ ) {
76+ await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId));
5277 }
5378 },
54− [handleNewThread, routeThreadId],
79+ [getCurrentRouteThreadRef, handleNewThread, resolveThreadTarget],
5580 );
5681
57− const unarchiveThread = useCallback(async (threadId: ThreadId) => {
58− const api = readNativeApi();
82+ const unarchiveThread = useCallback(async (target: ScopedThreadRef) => {
83+ const api = readEnvironmentApi(target.environmentId);
5984 if (!api) return;
6085 await api.orchestration.dispatchCommand({
6186 type: "thread.unarchive",
6287 commandId: newCommandId(),
63− threadId,
88+ threadId: target.threadId,
6489 });
6590 }, []);
6691
6792 const deleteThread = useCallback(
68− async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => {
69− const api = readNativeApi();
93+ async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => {
94+ const api = readEnvironmentApi(target.environmentId);
7095 if (!api) return;
96+ const resolved = resolveThreadTarget(target);
97+ if (!resolved) return;
98+ const { thread, threadRef } = resolved;
7199 const state = useStore.getState();
72− const threads = selectThreads(state);
73− const thread = selectThreadById(threadId)(state);
74− if (!thread) return;
75− const threadProject = selectProjectById(thread.projectId)(state);
76− const deletedIds = opts.deletedThreadIds;
100+ const threads = selectThreadsForEnvironment(state, threadRef.environmentId);
101+ const threadProject = selectProjectByRef(state, {
102+ environmentId: threadRef.environmentId,
103+ projectId: thread.projectId,
104+ });
105+ const deletedIds =
106+ opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0
107+ ? new Set<ThreadId>(
108+ [...opts.deletedThreadKeys].flatMap((threadKey) => {
109+ const ref = parseScopedThreadKey(threadKey);
110+ return ref && ref.environmentId === threadRef.environmentId ? [ref.threadId] : [];
111+ }),
112+ )
113+ : undefined;
77114 const survivingThreads =
78115 deletedIds && deletedIds.size > 0
79− ? threads.filter((entry) => entry.id === threadId || !deletedIds.has(entry.id))
116+ ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id))
80117 : threads;
81− const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId);
118+ const orphanedWorktreePath = getOrphanedWorktreePathForThread(
119+ survivingThreads,
120+ threadRef.threadId,
121+ );
82122 const displayWorktreePath = orphanedWorktreePath
83123 ? formatWorktreePathForDisplay(orphanedWorktreePath)
84124 : null;
85125 const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined;
126+ const localApi = readLocalApi();
86127 const shouldDeleteWorktree =
87128 canDeleteWorktree &&
88− (await api.dialogs.confirm(
129+ localApi &&
130+ (await localApi.dialogs.confirm(
89131 [
90132 "This thread is the only one linked to this worktree:",
91133 displayWorktreePath ?? orphanedWorktreePath,
@@ -99,44 +141,60 @@ export function useThreadActions() { · 7 unmodified lines
99141 .dispatchCommand({
100142 type: "thread.session.stop",
101143 commandId: newCommandId(),
102− threadId,
144+ threadId: threadRef.threadId,
103145 createdAt: new Date().toISOString(),
104146 })
105147 .catch(() => undefined);
106148 }
107149
108150 try {
109− await api.terminal.close({ threadId, deleteHistory: true });
151+ await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true });
110152 } catch {
111153 // Terminal may already be closed.
112154 }
113155
114− const deletedThreadIds = opts.deletedThreadIds ?? new Set<ThreadId>();
115− const shouldNavigateToFallback = routeThreadId === threadId;
156+ const deletedThreadIds = deletedIds ?? new Set<ThreadId>();
157+ const currentRouteThreadRef = getCurrentRouteThreadRef();
158+ const shouldNavigateToFallback =
159+ currentRouteThreadRef?.threadId === threadRef.threadId &&
160+ currentRouteThreadRef.environmentId === threadRef.environmentId;
116161 const fallbackThreadId = getFallbackThreadIdAfterDelete({
117162 threads,
118− deletedThreadId: threadId,
163+ deletedThreadId: threadRef.threadId,
119164 deletedThreadIds,
120− sortOrder: appSettings.sidebarThreadSortOrder,
165+ sortOrder: sidebarThreadSortOrder,
121166 });
122167 await api.orchestration.dispatchCommand({
123168 type: "thread.delete",
124169 commandId: newCommandId(),
125− threadId,
170+ threadId: threadRef.threadId,
126171 });
127− clearComposerDraftForThread(threadId);
128− clearProjectDraftThreadById(thread.projectId, thread.id);
129− clearTerminalState(threadId);
172+ clearComposerDraftForThread(threadRef);
173+ clearProjectDraftThreadById(
174+ scopeProjectRef(threadRef.environmentId, thread.projectId),
175+ threadRef,
176+ );
177+ clearTerminalState(threadRef);
130178
131179 if (shouldNavigateToFallback) {
132180 if (fallbackThreadId) {
133− await navigate({
134− to: "/$threadId",
135− params: { threadId: fallbackThreadId },
136− replace: true,
137− });
181+ const fallbackThread = selectThreadByRef(
182+ useStore.getState(),
183+ scopeThreadRef(threadRef.environmentId, fallbackThreadId),
184+ );
185+ if (fallbackThread) {
186+ await router.navigate({
187+ to: "/$environmentId/$threadId",
188+ params: buildThreadRouteParams(
189+ scopeThreadRef(fallbackThread.environmentId, fallbackThread.id),
190+ ),
191+ replace: true,
192+ });
193+ } else {
194+ await router.navigate({ to: "/", replace: true });
195+ }
138196 } else {
139− await navigate({ to: "/", replace: true });
197+ await router.navigate({ to: "/", replace: true });
140198 }
141199 }
142200
@@ -145,15 +203,18 @@ export function useThreadActions() { · 2 unmodified lines
145203 }
146204
147205 try {
148− await removeWorktreeMutation.mutateAsync({
206+ await ensureEnvironmentApi(threadRef.environmentId).git.removeWorktree({
149207 cwd: threadProject.cwd,
150208 path: orphanedWorktreePath,
151209 force: true,
152210 });
211+ await invalidateGitQueries(queryClient, {
212+ environmentId: threadRef.environmentId,
213+ });
153214 } catch (error) {
154215 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";
155216 console.error("Failed to remove orphaned worktree after thread deletion", {
156− threadId,
217+ threadId: threadRef.threadId,
157218 projectCwd: threadProject.cwd,
158219 worktreePath: orphanedWorktreePath,
159220 error,
@@ -169,22 +230,25 @@ export function useThreadActions() { · 9 unmodified lines
169230 clearComposerDraftForThread,
170231 clearProjectDraftThreadById,
171232 clearTerminalState,
172− appSettings.sidebarThreadSortOrder,
173− navigate,
174− removeWorktreeMutation,
175− routeThreadId,
233+ getCurrentRouteThreadRef,
234+ router,
235+ queryClient,
236+ resolveThreadTarget,
237+ sidebarThreadSortOrder,
176238 ],
177239 );
178240
179241 const confirmAndDeleteThread = useCallback(
180− async (threadId: ThreadId) => {
181− const api = readNativeApi();
242+ async (target: ScopedThreadRef) => {
243+ const api = readEnvironmentApi(target.environmentId);
182244 if (!api) return;
183− const thread = selectThreadById(threadId)(useStore.getState());
184− if (!thread) return;
245+ const localApi = readLocalApi();
246+ const resolved = resolveThreadTarget(target);
247+ if (!resolved) return;
248+ const { thread } = resolved;
185249
186− if (appSettings.confirmThreadDelete) {
187− const confirmed = await api.dialogs.confirm(
250+ if (confirmThreadDelete && localApi) {
251+ const confirmed = await localApi.dialogs.confirm(
188252 [
189253 `Delete thread "${thread.title}"?`,
190254 "This permanently clears conversation history for this thread.",
@@ -195,9 +259,9 @@ export function useThreadActions() { · 4 unmodified lines
195259 }
196260 }
197261
198− await deleteThread(threadId);
262+ await deleteThread(target);
199263 },
200− [appSettings.confirmThreadDelete, deleteThread],
264+ [confirmThreadDelete, deleteThread, resolveThreadTarget],
201265 );
202266
203267 return {
apps/web/src/lib/gitReactQuery.test.ts+30−13
@@ -1,16 +1,17 @@
11 import { QueryClient } from "@tanstack/react-query";
22 import { describe, expect, it, vi } from "vitest";
33
4−vi.mock("../nativeApi", () => ({
5− ensureNativeApi: vi.fn(),
4+vi.mock("../environmentApi", () => ({
5+ ensureEnvironmentApi: vi.fn(),
66 }));
77
88 vi.mock("../wsRpcClient", () => ({
99 getWsRpcClient: vi.fn(),
10+ getWsRpcClientForEnvironment: vi.fn(),
1011 }));
1112
1213 import type { InfiniteData } from "@tanstack/react-query";
13−import type { GitListBranchesResult } from "@t3tools/contracts";
14+import { EnvironmentId, type GitListBranchesResult } from "@t3tools/contracts";
1415
1516 import {
1617 gitBranchSearchInfiniteQueryOptions,
@@ -33,21 +34,25 @@ const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = { · 16 unmodified lines
3334 pages: [BRANCH_QUERY_RESULT],
3435 pageParams: [0],
3536 };
37+const ENVIRONMENT_A = EnvironmentId.makeUnsafe("environment-a");
38+const ENVIRONMENT_B = EnvironmentId.makeUnsafe("environment-b");
3639
3740 describe("gitMutationKeys", () => {
3841 it("scopes stacked action keys by cwd", () => {
39− expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual(
40− gitMutationKeys.runStackedAction("/repo/b"),
42+ expect(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")).not.toEqual(
43+ gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/b"),
4144 );
4245 });
4346
4447 it("scopes pull keys by cwd", () => {
45− expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b"));
48+ expect(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")).not.toEqual(
49+ gitMutationKeys.pull(ENVIRONMENT_A, "/repo/b"),
50+ );
4651 });
4752
4853 it("scopes pull request thread preparation keys by cwd", () => {
49− expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual(
50− gitMutationKeys.preparePullRequestThread("/repo/b"),
54+ expect(gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a")).not.toEqual(
55+ gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/b"),
5156 );
5257 });
5358 });
@@ -57,23 +62,31 @@ describe("git mutation options", () => { · 3 unmodified lines
5762
5863 it("attaches cwd-scoped mutation key for runStackedAction", () => {
5964 const options = gitRunStackedActionMutationOptions({
65+ environmentId: ENVIRONMENT_A,
6066 cwd: "/repo/a",
6167 queryClient,
6268 });
63− expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a"));
69+ expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a"));
6470 });
6571
6672 it("attaches cwd-scoped mutation key for pull", () => {
67− const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });
68− expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));
73+ const options = gitPullMutationOptions({
74+ environmentId: ENVIRONMENT_A,
75+ cwd: "/repo/a",
76+ queryClient,
77+ });
78+ expect(options.mutationKey).toEqual(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a"));
6979 });
7080
7181 it("attaches cwd-scoped mutation key for preparePullRequestThread", () => {
7282 const options = gitPreparePullRequestThreadMutationOptions({
83+ environmentId: ENVIRONMENT_A,
7384 cwd: "/repo/a",
7485 queryClient,
7586 });
76− expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a"));
87+ expect(options.mutationKey).toEqual(
88+ gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a"),
89+ );
7790 });
7891 });
7992
@@ -83,6 +96,7 @@ describe("invalidateGitQueries", () => { · 3 unmodified lines
8396
8497 queryClient.setQueryData(
8598 gitBranchSearchInfiniteQueryOptions({
99+ environmentId: ENVIRONMENT_A,
86100 cwd: "/repo/a",
87101 query: "feature",
88102 }).queryKey,
@@ -90,17 +104,19 @@ describe("invalidateGitQueries", () => { · 1 unmodified line
90104 );
91105 queryClient.setQueryData(
92106 gitBranchSearchInfiniteQueryOptions({
107+ environmentId: ENVIRONMENT_B,
93108 cwd: "/repo/b",
94109 query: "feature",
95110 }).queryKey,
96111 BRANCH_SEARCH_RESULT,
97112 );
98113
99− await invalidateGitQueries(queryClient, { cwd: "/repo/a" });
114+ await invalidateGitQueries(queryClient, { environmentId: ENVIRONMENT_A, cwd: "/repo/a" });
100115
101116 expect(
102117 queryClient.getQueryState(
103118 gitBranchSearchInfiniteQueryOptions({
119+ environmentId: ENVIRONMENT_A,
104120 cwd: "/repo/a",
105121 query: "feature",
106122 }).queryKey,
@@ -109,6 +125,7 @@ describe("invalidateGitQueries", () => { · 2 unmodified lines
109125 expect(
110126 queryClient.getQueryState(
111127 gitBranchSearchInfiniteQueryOptions({
128+ environmentId: ENVIRONMENT_B,
112129 cwd: "/repo/b",
113130 query: "feature",
114131 }).queryKey,
apps/web/src/lib/gitReactQuery.ts+105−53
@@ -1,4 +1,5 @@
11 import {
2+ type EnvironmentId,
23 type GitActionProgressEvent,
34 type GitStackedAction,
45 type ThreadId,
@@ -9,8 +10,8 @@ import { · 4 unmodified lines
910 queryOptions,
1011 type QueryClient,
1112 } from "@tanstack/react-query";
12−import { ensureNativeApi } from "../nativeApi";
13−import { getWsRpcClient } from "../wsRpcClient";
13+import { ensureEnvironmentApi } from "../environmentApi";
14+import { getWsRpcClientForEnvironment } from "../wsRpcClient";
1415
1516 const GIT_BRANCHES_STALE_TIME_MS = 15_000;
1617 const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
@@ -18,38 +19,52 @@ const GIT_BRANCHES_PAGE_SIZE = 100; · 1 unmodified line
1819
1920 export const gitQueryKeys = {
2021 all: ["git"] as const,
21− branches: (cwd: string | null) => ["git", "branches", cwd] as const,
22− branchSearch: (cwd: string | null, query: string) =>
23− ["git", "branches", cwd, "search", query] as const,
22+ branches: (environmentId: EnvironmentId | null, cwd: string | null) =>
23+ ["git", "branches", environmentId ?? null, cwd] as const,
24+ branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) =>
25+ ["git", "branches", environmentId ?? null, cwd, "search", query] as const,
2426 };
2527
2628 export const gitMutationKeys = {
27− init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const,
28− checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const,
29− runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const,
30− pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,
31− preparePullRequestThread: (cwd: string | null) =>
32− ["git", "mutation", "prepare-pull-request-thread", cwd] as const,
29+ init: (environmentId: EnvironmentId | null, cwd: string | null) =>
30+ ["git", "mutation", "init", environmentId ?? null, cwd] as const,
31+ checkout: (environmentId: EnvironmentId | null, cwd: string | null) =>
32+ ["git", "mutation", "checkout", environmentId ?? null, cwd] as const,
33+ runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) =>
34+ ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const,
35+ pull: (environmentId: EnvironmentId | null, cwd: string | null) =>
36+ ["git", "mutation", "pull", environmentId ?? null, cwd] as const,
37+ preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) =>
38+ ["git", "mutation", "prepare-pull-request-thread", environmentId ?? null, cwd] as const,
3339 };
3440
35−export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) {
41+export function invalidateGitQueries(
42+ queryClient: QueryClient,
43+ input?: { environmentId?: EnvironmentId | null; cwd?: string | null },
44+) {
45+ const environmentId = input?.environmentId ?? null;
3646 const cwd = input?.cwd ?? null;
3747 if (cwd !== null) {
38− return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) });
48+ return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) });
3949 }
4050
4151 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
4252 }
4353
44−function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null) {
54+function invalidateGitBranchQueries(
55+ queryClient: QueryClient,
56+ environmentId: EnvironmentId | null,
57+ cwd: string | null,
58+) {
4559 if (cwd === null) {
4660 return Promise.resolve();
4761 }
4862
49− return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) });
63+ return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) });
5064 }
5165
5266 export function gitBranchSearchInfiniteQueryOptions(input: {
67+ environmentId: EnvironmentId | null;
5368 cwd: string | null;
5469 query: string;
5570 enabled?: boolean;
@@ -57,11 +72,12 @@ export function gitBranchSearchInfiniteQueryOptions(input: { · 1 unmodified line
5772 const normalizedQuery = input.query.trim();
5873
5974 return infiniteQueryOptions({
60− queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery),
75+ queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery),
6176 initialPageParam: 0,
6277 queryFn: async ({ pageParam }) => {
63− const api = ensureNativeApi();
6478 if (!input.cwd) throw new Error("Git branches are unavailable.");
79+ if (!input.environmentId) throw new Error("Git branches are unavailable.");
80+ const api = ensureEnvironmentApi(input.environmentId);
6581 return api.git.listBranches({
6682 cwd: input.cwd,
6783 ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}),
@@ -79,62 +95,75 @@ export function gitBranchSearchInfiniteQueryOptions(input: { · 11 unmodified lines
7995 }
8096
8197 export function gitResolvePullRequestQueryOptions(input: {
98+ environmentId: EnvironmentId | null;
8299 cwd: string | null;
83100 reference: string | null;
84101 }) {
85102 return queryOptions({
86− queryKey: ["git", "pull-request", input.cwd, input.reference] as const,
103+ queryKey: [
104+ "git",
105+ "pull-request",
106+ input.environmentId ?? null,
107+ input.cwd,
108+ input.reference,
109+ ] as const,
87110 queryFn: async () => {
88− const api = ensureNativeApi();
89− if (!input.cwd || !input.reference) {
111+ if (!input.cwd || !input.reference || !input.environmentId) {
90112 throw new Error("Pull request lookup is unavailable.");
91113 }
114+ const api = ensureEnvironmentApi(input.environmentId);
92115 return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference });
93116 },
94− enabled: input.cwd !== null && input.reference !== null,
117+ enabled: input.environmentId !== null && input.cwd !== null && input.reference !== null,
95118 staleTime: 30_000,
96119 refetchOnWindowFocus: false,
97120 refetchOnReconnect: false,
98121 });
99122 }
100123
101−export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
124+export function gitInitMutationOptions(input: {
125+ environmentId: EnvironmentId | null;
126+ cwd: string | null;
127+ queryClient: QueryClient;
128+}) {
102129 return mutationOptions({
103− mutationKey: gitMutationKeys.init(input.cwd),
130+ mutationKey: gitMutationKeys.init(input.environmentId, input.cwd),
104131 mutationFn: async () => {
105− const api = ensureNativeApi();
106− if (!input.cwd) throw new Error("Git init is unavailable.");
132+ if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable.");
133+ const api = ensureEnvironmentApi(input.environmentId);
107134 return api.git.init({ cwd: input.cwd });
108135 },
109136 onSettled: async () => {
110− await invalidateGitBranchQueries(input.queryClient, input.cwd);
137+ await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
111138 },
112139 });
113140 }
114141
115142 export function gitCheckoutMutationOptions(input: {
143+ environmentId: EnvironmentId | null;
116144 cwd: string | null;
117145 queryClient: QueryClient;
118146 }) {
119147 return mutationOptions({
120− mutationKey: gitMutationKeys.checkout(input.cwd),
148+ mutationKey: gitMutationKeys.checkout(input.environmentId, input.cwd),
121149 mutationFn: async (branch: string) => {
122− const api = ensureNativeApi();
123− if (!input.cwd) throw new Error("Git checkout is unavailable.");
150+ if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable.");
151+ const api = ensureEnvironmentApi(input.environmentId);
124152 return api.git.checkout({ cwd: input.cwd, branch });
125153 },
126154 onSettled: async () => {
127− await invalidateGitBranchQueries(input.queryClient, input.cwd);
155+ await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
128156 },
129157 });
130158 }
131159
132160 export function gitRunStackedActionMutationOptions(input: {
161+ environmentId: EnvironmentId | null;
133162 cwd: string | null;
134163 queryClient: QueryClient;
135164 }) {
136165 return mutationOptions({
137− mutationKey: gitMutationKeys.runStackedAction(input.cwd),
166+ mutationKey: gitMutationKeys.runStackedAction(input.environmentId, input.cwd),
138167 mutationFn: async ({
139168 actionId,
140169 action,
@@ -150,8 +179,8 @@ export function gitRunStackedActionMutationOptions(input: { · 9 unmodified lines
150179 filePaths?: string[];
151180 onProgress?: (event: GitActionProgressEvent) => void;
152181 }) => {
153− if (!input.cwd) throw new Error("Git action is unavailable.");
154− return getWsRpcClient().git.runStackedAction(
182+ if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable.");
183+ return getWsRpcClientForEnvironment(input.environmentId).git.runStackedAction(
155184 {
156185 action,
157186 actionId,
@@ -164,62 +193,85 @@ export function gitRunStackedActionMutationOptions(input: { · 6 unmodified lines
164193 );
165194 },
166195 onSuccess: async () => {
167− await invalidateGitBranchQueries(input.queryClient, input.cwd);
196+ await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
168197 },
169198 });
170199 }
171200
172−export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
201+export function gitPullMutationOptions(input: {
202+ environmentId: EnvironmentId | null;
203+ cwd: string | null;
204+ queryClient: QueryClient;
205+}) {
173206 return mutationOptions({
174− mutationKey: gitMutationKeys.pull(input.cwd),
207+ mutationKey: gitMutationKeys.pull(input.environmentId, input.cwd),
175208 mutationFn: async () => {
176− const api = ensureNativeApi();
177− if (!input.cwd) throw new Error("Git pull is unavailable.");
209+ if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable.");
210+ const api = ensureEnvironmentApi(input.environmentId);
178211 return api.git.pull({ cwd: input.cwd });
179212 },
180213 onSuccess: async () => {
181− await invalidateGitBranchQueries(input.queryClient, input.cwd);
214+ await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
182215 },
183216 });
184217 }
185218
186−export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) {
219+export function gitCreateWorktreeMutationOptions(input: {
220+ environmentId: EnvironmentId | null;
221+ queryClient: QueryClient;
222+}) {
187223 return mutationOptions({
188− mutationKey: ["git", "mutation", "create-worktree"] as const,
224+ mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const,
189225 mutationFn: (
190− args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["createWorktree"]>[0],
191− ) => ensureNativeApi().git.createWorktree(args),
226+ args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["createWorktree"]>[0],
227+ ) => {
228+ if (!input.environmentId) {
229+ throw new Error("Worktree creation is unavailable.");
230+ }
231+ return ensureEnvironmentApi(input.environmentId).git.createWorktree(args);
232+ },
192233 onSuccess: async () => {
193− await invalidateGitQueries(input.queryClient);
234+ await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
194235 },
195236 });
196237 }
197238
198−export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) {
239+export function gitRemoveWorktreeMutationOptions(input: {
240+ environmentId: EnvironmentId | null;
241+ queryClient: QueryClient;
242+}) {
199243 return mutationOptions({
200− mutationKey: ["git", "mutation", "remove-worktree"] as const,
244+ mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const,
201245 mutationFn: (
202− args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["removeWorktree"]>[0],
203− ) => ensureNativeApi().git.removeWorktree(args),
246+ args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["removeWorktree"]>[0],
247+ ) => {
248+ if (!input.environmentId) {
249+ throw new Error("Worktree removal is unavailable.");
250+ }
251+ return ensureEnvironmentApi(input.environmentId).git.removeWorktree(args);
252+ },
204253 onSuccess: async () => {
205− await invalidateGitQueries(input.queryClient);
254+ await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
206255 },
207256 });
208257 }
209258
210259 export function gitPreparePullRequestThreadMutationOptions(input: {
260+ environmentId: EnvironmentId | null;
211261 cwd: string | null;
212262 queryClient: QueryClient;
213263 }) {
214264 return mutationOptions({
215− mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd),
265+ mutationKey: gitMutationKeys.preparePullRequestThread(input.environmentId, input.cwd),
216266 mutationFn: async (args: {
217267 reference: string;
218268 mode: "local" | "worktree";
219269 threadId?: ThreadId;
220270 }) => {
221− const api = ensureNativeApi();
222− if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");
271+ if (!input.cwd || !input.environmentId) {
272+ throw new Error("Pull request thread preparation is unavailable.");
273+ }
274+ const api = ensureEnvironmentApi(input.environmentId);
223275 return api.git.preparePullRequestThread({
224276 cwd: input.cwd,
225277 reference: args.reference,
@@ -228,7 +280,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: { · 2 unmodified lines
228280 });
229281 },
230282 onSuccess: async () => {
231− await invalidateGitBranchQueries(input.queryClient, input.cwd);
283+ await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
232284 },
233285 });
234286 }
apps/web/src/lib/gitStatusState.test.ts+47−9
@@ -1,4 +1,4 @@
1−import type { GitStatusResult } from "@t3tools/contracts";
1+import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts";
22 import { afterEach, describe, expect, it, vi } from "vitest";
33
44 import {
@@ -16,6 +16,10 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even · 11 unmodified lines
1616 }
1717
1818 const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
19+const ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
20+const OTHER_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-remote");
21+const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const;
22+const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const;
1923
2024 const BASE_STATUS: GitStatusResult = {
2125 isRepo: true,
@@ -55,7 +59,7 @@ afterEach(() => { · 33 unmodified lines
5559
5660 describe("gitStatusState", () => {
5761 it("starts fresh cwd state in a pending state", () => {
58− expect(getGitStatusSnapshot("/fresh")).toEqual({
62+ expect(getGitStatusSnapshot(FRESH_TARGET)).toEqual({
5963 data: null,
6064 error: null,
6165 cause: null,
@@ -64,11 +68,11 @@ describe("gitStatusState", () => { · 2 unmodified lines
6468 });
6569
6670 it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => {
67− const releaseA = watchGitStatus("/repo", gitClient);
68− const releaseB = watchGitStatus("/repo", gitClient);
71+ const releaseA = watchGitStatus(TARGET, gitClient);
72+ const releaseB = watchGitStatus(TARGET, gitClient);
6973
7074 expect(gitClient.onStatus).toHaveBeenCalledOnce();
71− expect(getGitStatusSnapshot("/repo")).toEqual({
75+ expect(getGitStatusSnapshot(TARGET)).toEqual({
7276 data: null,
7377 error: null,
7478 cause: null,
@@ -77,7 +81,7 @@ describe("gitStatusState", () => { · 2 unmodified lines
7781
7882 emitGitStatus(BASE_STATUS);
7983
80− expect(getGitStatusSnapshot("/repo")).toEqual({
84+ expect(getGitStatusSnapshot(TARGET)).toEqual({
8185 data: BASE_STATUS,
8286 error: null,
8387 cause: null,
@@ -92,15 +96,15 @@ describe("gitStatusState", () => { · 8 unmodified lines
9296 });
9397
9498 it("refreshes git status through the unary RPC without restarting the stream", async () => {
95− const release = watchGitStatus("/repo", gitClient);
99+ const release = watchGitStatus(TARGET, gitClient);
96100
97101 emitGitStatus(BASE_STATUS);
98− const refreshed = await refreshGitStatus("/repo", gitClient);
102+ const refreshed = await refreshGitStatus(TARGET, gitClient);
99103
100104 expect(gitClient.onStatus).toHaveBeenCalledOnce();
101105 expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
102106 expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" });
103− expect(getGitStatusSnapshot("/repo")).toEqual({
107+ expect(getGitStatusSnapshot(TARGET)).toEqual({
104108 data: BASE_STATUS,
105109 error: null,
106110 cause: null,
@@ -109,4 +113,38 @@ describe("gitStatusState", () => { · 2 unmodified lines
109113
110114 release();
111115 });
116+
117+ it("keeps git status subscriptions isolated by environment when cwds match", () => {
118+ const localListeners = new Set<(event: GitStatusResult) => void>();
119+ const remoteListeners = new Set<(event: GitStatusResult) => void>();
120+ const localClient = {
121+ refreshStatus: vi.fn(),
122+ onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) =>
123+ registerListener(localListeners, listener),
124+ ),
125+ };
126+ const remoteClient = {
127+ refreshStatus: vi.fn(),
128+ onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) =>
129+ registerListener(remoteListeners, listener),
130+ ),
131+ };
132+ const remoteTarget = { environmentId: OTHER_ENVIRONMENT_ID, cwd: "/repo" } as const;
133+
134+ const releaseLocal = watchGitStatus(TARGET, localClient);
135+ const releaseRemote = watchGitStatus(remoteTarget, remoteClient);
136+
137+ for (const listener of localListeners) {
138+ listener(BASE_STATUS);
139+ }
140+ for (const listener of remoteListeners) {
141+ listener({ ...BASE_STATUS, branch: "remote-branch" });
142+ }
143+
144+ expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status");
145+ expect(getGitStatusSnapshot(remoteTarget).data?.branch).toBe("remote-branch");
146+
147+ releaseLocal();
148+ releaseRemote();
149+ });
112150 });
apps/web/src/lib/gitStatusState.ts+78−76
@@ -1,11 +1,15 @@
11 import { useAtomValue } from "@effect/atom-react";
2−import { type GitManagerServiceError, type GitStatusResult } from "@t3tools/contracts";
2+import {
3+ type EnvironmentId,
4+ type GitManagerServiceError,
5+ type GitStatusResult,
6+} from "@t3tools/contracts";
37 import { Cause } from "effect";
48 import { Atom } from "effect/unstable/reactivity";
59 import { useEffect } from "react";
610
711 import { appAtomRegistry } from "../rpc/atomRegistry";
8−import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient";
12+import { getWsRpcClient, getWsRpcClientForEnvironment, type WsRpcClient } from "../wsRpcClient";
913
1014 export type GitStatusStreamError = GitManagerServiceError;
1115
@@ -23,6 +27,11 @@ interface WatchedGitStatus { · 11 unmodified lines
2327 unsubscribe: () => void;
2428 }
2529
30+export interface GitStatusTarget {
31+ readonly environmentId: EnvironmentId | null;
32+ readonly cwd: string | null;
33+}
34+
2635 const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
2736 data: null,
2837 error: null,
@@ -40,79 +49,91 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( · 11 unmodified lines
4049
4150 const NOOP: () => void = () => undefined;
4251 const watchedGitStatuses = new Map<string, WatchedGitStatus>();
43−const knownGitStatusCwds = new Set<string>();
52+const knownGitStatusKeys = new Set<string>();
4453 const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>();
45−const gitStatusLastRefreshAtByCwd = new Map<string, number>();
54+const gitStatusLastRefreshAtByKey = new Map<string, number>();
4655
4756 const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
4857
49−let sharedGitStatusClient: GitStatusClient | null = null;
50−
51−const gitStatusStateAtom = Atom.family((cwd: string) => {
52− knownGitStatusCwds.add(cwd);
58+const gitStatusStateAtom = Atom.family((key: string) => {
59+ knownGitStatusKeys.add(key);
5360 return Atom.make(INITIAL_GIT_STATUS_STATE).pipe(
5461 Atom.keepAlive,
55− Atom.withLabel(`git-status:${cwd}`),
62+ Atom.withLabel(`git-status:${key}`),
5663 );
5764 });
5865
59−export function getGitStatusSnapshot(cwd: string | null): GitStatusState {
60− if (cwd === null) {
66+function getGitStatusTargetKey(target: GitStatusTarget): string | null {
67+ if (target.cwd === null) {
68+ return null;
69+ }
70+
71+ return `${target.environmentId ?? "__default__"}:${target.cwd}`;
72+}
73+
74+function resolveGitStatusClient(target: GitStatusTarget): GitStatusClient {
75+ if (target.environmentId) {
76+ return getWsRpcClientForEnvironment(target.environmentId).git;
77+ }
78+ return getWsRpcClient().git;
79+}
80+
81+export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState {
82+ const targetKey = getGitStatusTargetKey(target);
83+ if (targetKey === null) {
6184 return EMPTY_GIT_STATUS_STATE;
6285 }
6386
64− return appAtomRegistry.get(gitStatusStateAtom(cwd));
87+ return appAtomRegistry.get(gitStatusStateAtom(targetKey));
6588 }
6689
6790 export function watchGitStatus(
68− cwd: string | null,
69− client: GitStatusClient = getWsRpcClient().git,
91+ target: GitStatusTarget,
92+ client: GitStatusClient = resolveGitStatusClient(target),
7093 ): () => void {
71− if (cwd === null) {
94+ const targetKey = getGitStatusTargetKey(target);
95+ if (targetKey === null) {
7296 return NOOP;
7397 }
7498
75− ensureGitStatusClient(client);
76−
77− const watched = watchedGitStatuses.get(cwd);
99+ const watched = watchedGitStatuses.get(targetKey);
78100 if (watched) {
79101 watched.refCount += 1;
80− return () => unwatchGitStatus(cwd);
102+ return () => unwatchGitStatus(targetKey);
81103 }
82104
83− watchedGitStatuses.set(cwd, {
105+ watchedGitStatuses.set(targetKey, {
84106 refCount: 1,
85− unsubscribe: subscribeToGitStatus(cwd),
107+ unsubscribe: subscribeToGitStatus(targetKey, target.cwd!, client),
86108 });
87109
88− return () => unwatchGitStatus(cwd);
110+ return () => unwatchGitStatus(targetKey);
89111 }
90112
91113 export function refreshGitStatus(
92− cwd: string | null,
93− client: GitStatusClient = getWsRpcClient().git,
114+ target: GitStatusTarget,
115+ client: GitStatusClient = resolveGitStatusClient(target),
94116 ): Promise<GitStatusResult | null> {
95− if (cwd === null) {
117+ const targetKey = getGitStatusTargetKey(target);
118+ if (targetKey === null || target.cwd === null) {
96119 return Promise.resolve(null);
97120 }
98121
99− ensureGitStatusClient(client);
100−
101− const currentInFlight = gitStatusRefreshInFlight.get(cwd);
122+ const currentInFlight = gitStatusRefreshInFlight.get(targetKey);
102123 if (currentInFlight) {
103124 return currentInFlight;
104125 }
105126
106− const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0;
127+ const lastRequestedAt = gitStatusLastRefreshAtByKey.get(targetKey) ?? 0;
107128 if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) {
108− return Promise.resolve(getGitStatusSnapshot(cwd).data);
129+ return Promise.resolve(getGitStatusSnapshot(target).data);
109130 }
110131
111− gitStatusLastRefreshAtByCwd.set(cwd, Date.now());
112− const refreshPromise = client.refreshStatus({ cwd }).finally(() => {
113− gitStatusRefreshInFlight.delete(cwd);
132+ gitStatusLastRefreshAtByKey.set(targetKey, Date.now());
133+ const refreshPromise = client.refreshStatus({ cwd: target.cwd }).finally(() => {
134+ gitStatusRefreshInFlight.delete(targetKey);
114135 });
115− gitStatusRefreshInFlight.set(cwd, refreshPromise);
136+ gitStatusRefreshInFlight.set(targetKey, refreshPromise);
116137 return refreshPromise;
117138 }
118139
@@ -122,43 +143,29 @@ export function resetGitStatusStateForTests(): void { · 3 unmodified lines
122143 }
123144 watchedGitStatuses.clear();
124145 gitStatusRefreshInFlight.clear();
125− gitStatusLastRefreshAtByCwd.clear();
126− sharedGitStatusClient = null;
146+ gitStatusLastRefreshAtByKey.clear();
127147
128− for (const cwd of knownGitStatusCwds) {
129− appAtomRegistry.set(gitStatusStateAtom(cwd), INITIAL_GIT_STATUS_STATE);
148+ for (const key of knownGitStatusKeys) {
149+ appAtomRegistry.set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE);
130150 }
131− knownGitStatusCwds.clear();
132−}
133−
134−export function useGitStatus(cwd: string | null): GitStatusState {
135− useEffect(() => watchGitStatus(cwd), [cwd]);
136−
137− const state = useAtomValue(cwd !== null ? gitStatusStateAtom(cwd) : EMPTY_GIT_STATUS_ATOM);
138− return cwd === null ? EMPTY_GIT_STATUS_STATE : state;
151+ knownGitStatusKeys.clear();
139152 }
140153
141−function ensureGitStatusClient(client: GitStatusClient): void {
142− if (sharedGitStatusClient === client) {
143− return;
144− }
145−
146− if (sharedGitStatusClient !== null) {
147− resetLiveGitStatusSubscriptions();
148− }
149−
150− sharedGitStatusClient = client;
151−}
154+export function useGitStatus(target: GitStatusTarget): GitStatusState {
155+ const targetKey = getGitStatusTargetKey(target);
156+ useEffect(
157+ () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }),
158+ [target.environmentId, target.cwd],
159+ );
152160
153−function resetLiveGitStatusSubscriptions(): void {
154− for (const watched of watchedGitStatuses.values()) {
155− watched.unsubscribe();
156− }
157− watchedGitStatuses.clear();
161+ const state = useAtomValue(
162+ targetKey !== null ? gitStatusStateAtom(targetKey) : EMPTY_GIT_STATUS_ATOM,
163+ );
164+ return targetKey === null ? EMPTY_GIT_STATUS_STATE : state;
158165 }
159166
160−function unwatchGitStatus(cwd: string): void {
161− const watched = watchedGitStatuses.get(cwd);
167+function unwatchGitStatus(targetKey: string): void {
168+ const watched = watchedGitStatuses.get(targetKey);
162169 if (!watched) {
163170 return;
164171 }
@@ -169,20 +176,15 @@ function unwatchGitStatus(cwd: string): void { · 4 unmodified lines
169176 }
170177
171178 watched.unsubscribe();
172− watchedGitStatuses.delete(cwd);
179+ watchedGitStatuses.delete(targetKey);
173180 }
174181
175−function subscribeToGitStatus(cwd: string): () => void {
176− const client = sharedGitStatusClient;
177− if (!client) {
178− return NOOP;
179− }
180−
181− markGitStatusPending(cwd);
182+function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void {
183+ markGitStatusPending(targetKey);
182184 return client.onStatus(
183185 { cwd },
184186 (status) => {
185− appAtomRegistry.set(gitStatusStateAtom(cwd), {
187+ appAtomRegistry.set(gitStatusStateAtom(targetKey), {
186188 data: status,
187189 error: null,
188190 cause: null,
@@ -191,14 +193,14 @@ function subscribeToGitStatus(cwd: string): () => void { · 2 unmodified lines
191193 },
192194 {
193195 onResubscribe: () => {
194− markGitStatusPending(cwd);
196+ markGitStatusPending(targetKey);
195197 },
196198 },
197199 );
198200 }
199201
200−function markGitStatusPending(cwd: string): void {
201− const atom = gitStatusStateAtom(cwd);
202+function markGitStatusPending(targetKey: string): void {
203+ const atom = gitStatusStateAtom(targetKey);
202204 const current = appAtomRegistry.get(atom);
203205 const next =
204206 current.data === null
apps/web/src/lib/projectReactQuery.ts+17−8
@@ -1,11 +1,15 @@
1−import type { ProjectSearchEntriesResult } from "@t3tools/contracts";
1+import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts";
22 import { queryOptions } from "@tanstack/react-query";
3−import { ensureNativeApi } from "~/nativeApi";
3+import { ensureEnvironmentApi } from "~/environmentApi";
44
55 export const projectQueryKeys = {
66 all: ["projects"] as const,
7− searchEntries: (cwd: string | null, query: string, limit: number) =>
8− ["projects", "search-entries", cwd, query, limit] as const,
7+ searchEntries: (
8+ environmentId: EnvironmentId | null,
9+ cwd: string | null,
10+ query: string,
11+ limit: number,
12+ ) => ["projects", "search-entries", environmentId ?? null, cwd, query, limit] as const,
913 };
1014
1115 const DEFAULT_SEARCH_ENTRIES_LIMIT = 80;
@@ -16,6 +20,7 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { · 4 unmodified lines
1620 };
1721
1822 export function projectSearchEntriesQueryOptions(input: {
23+ environmentId: EnvironmentId | null;
1924 cwd: string | null;
2025 query: string;
2126 enabled?: boolean;
@@ -24,19 +29,23 @@ export function projectSearchEntriesQueryOptions(input: { · 2 unmodified lines
2429 }) {
2530 const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT;
2631 return queryOptions({
27− queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit),
32+ queryKey: projectQueryKeys.searchEntries(input.environmentId, input.cwd, input.query, limit),
2833 queryFn: async () => {
29− const api = ensureNativeApi();
30− if (!input.cwd) {
34+ if (!input.cwd || !input.environmentId) {
3135 throw new Error("Workspace entry search is unavailable.");
3236 }
37+ const api = ensureEnvironmentApi(input.environmentId);
3338 return api.projects.searchEntries({
3439 cwd: input.cwd,
3540 query: input.query,
3641 limit,
3742 });
3843 },
39− enabled: (input.enabled ?? true) && input.cwd !== null && input.query.length > 0,
44+ enabled:
45+ (input.enabled ?? true) &&
46+ input.environmentId !== null &&
47+ input.cwd !== null &&
48+ input.query.length > 0,
4049 staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,
4150 placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,
4251 });
apps/web/src/lib/providerReactQuery.test.ts+11−4
@@ -1,21 +1,22 @@
1−import { ThreadId, type NativeApi } from "@t3tools/contracts";
1+import { EnvironmentId, ThreadId, type EnvironmentApi } from "@t3tools/contracts";
22 import { QueryClient } from "@tanstack/react-query";
33 import { afterEach, describe, expect, it, vi } from "vitest";
44 import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery";
5−import * as nativeApi from "../nativeApi";
5+import * as environmentApi from "../environmentApi";
66
77 const threadId = ThreadId.makeUnsafe("thread-id");
8+const environmentId = EnvironmentId.makeUnsafe("environment-local");
89
910 function mockNativeApi(input: {
1011 getTurnDiff: ReturnType<typeof vi.fn>;
1112 getFullThreadDiff: ReturnType<typeof vi.fn>;
1213 }) {
13− vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({
14+ vi.spyOn(environmentApi, "ensureEnvironmentApi").mockReturnValue({
1415 orchestration: {
1516 getTurnDiff: input.getTurnDiff,
1617 getFullThreadDiff: input.getFullThreadDiff,
1718 },
18− } as unknown as NativeApi);
19+ } as unknown as EnvironmentApi);
1920 }
2021
2122 afterEach(() => {
@@ -25,6 +26,7 @@ afterEach(() => { · 3 unmodified lines
2526 describe("providerQueryKeys.checkpointDiff", () => {
2627 it("includes cacheScope so reused turn counts do not collide", () => {
2728 const baseInput = {
29+ environmentId,
2830 threadId,
2931 fromTurnCount: 1,
3032 toTurnCount: 2,
@@ -51,6 +53,7 @@ describe("checkpointDiffQueryOptions", () => { · 20 unmodified lines
5153 mockNativeApi({ getTurnDiff, getFullThreadDiff });
5254
5355 const options = checkpointDiffQueryOptions({
56+ environmentId,
5457 threadId,
5558 fromTurnCount: 3,
5659 toTurnCount: 4,
@@ -74,6 +77,7 @@ describe("checkpointDiffQueryOptions", () => { · 17 unmodified lines
7477 mockNativeApi({ getTurnDiff, getFullThreadDiff });
7578
7679 const options = checkpointDiffQueryOptions({
80+ environmentId,
7781 threadId,
7882 fromTurnCount: 0,
7983 toTurnCount: 2,
@@ -96,6 +100,7 @@ describe("checkpointDiffQueryOptions", () => { · 16 unmodified lines
96100 mockNativeApi({ getTurnDiff, getFullThreadDiff });
97101
98102 const options = checkpointDiffQueryOptions({
103+ environmentId,
99104 threadId,
100105 fromTurnCount: 4,
101106 toTurnCount: 3,
@@ -113,6 +118,7 @@ describe("checkpointDiffQueryOptions", () => { · 11 unmodified lines
113118
114119 it("retries checkpoint-not-ready errors longer than generic failures", () => {
115120 const options = checkpointDiffQueryOptions({
121+ environmentId,
116122 threadId,
117123 fromTurnCount: 1,
118124 toTurnCount: 2,
@@ -137,6 +143,7 @@ describe("checkpointDiffQueryOptions", () => { · 18 unmodified lines
137143
138144 it("backs off longer for checkpoint-not-ready errors", () => {
139145 const options = checkpointDiffQueryOptions({
146+ environmentId,
140147 threadId,
141148 fromTurnCount: 1,
142149 toTurnCount: 2,
apps/web/src/lib/providerReactQuery.ts+11−4
@@ -1,13 +1,15 @@
11 import {
2+ type EnvironmentId,
23 OrchestrationGetFullThreadDiffInput,
34 OrchestrationGetTurnDiffInput,
45 ThreadId,
56 } from "@t3tools/contracts";
67 import { queryOptions } from "@tanstack/react-query";
78 import { Option, Schema } from "effect";
8−import { ensureNativeApi } from "../nativeApi";
9+import { ensureEnvironmentApi } from "../environmentApi";
910
1011 interface CheckpointDiffQueryInput {
12+ environmentId: EnvironmentId | null;
1113 threadId: ThreadId | null;
1214 fromTurnCount: number | null;
1315 toTurnCount: number | null;
@@ -21,6 +23,7 @@ export const providerQueryKeys = { · 7 unmodified lines
2123 [
2224 "providers",
2325 "checkpointDiff",
26+ input.environmentId ?? null,
2427 input.threadId,
2528 input.fromTurnCount,
2629 input.toTurnCount,
@@ -95,10 +98,10 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { · 68 unmodified lines
9598 return queryOptions({
9699 queryKey: providerQueryKeys.checkpointDiff(input),
97100 queryFn: async () => {
98− const api = ensureNativeApi();
99− if (!input.threadId || decodedRequest._tag === "None") {
101+ if (!input.environmentId || !input.threadId || decodedRequest._tag === "None") {
100102 throw new Error("Checkpoint diff is unavailable.");
101103 }
104+ const api = ensureEnvironmentApi(input.environmentId);
102105 try {
103106 if (decodedRequest.value.kind === "fullThreadDiff") {
104107 return await api.orchestration.getFullThreadDiff(decodedRequest.value.input);
@@ -108,7 +111,11 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { · 3 unmodified lines
108111 throw new Error(normalizeCheckpointErrorMessage(error), { cause: error });
109112 }
110113 },
111− enabled: (input.enabled ?? true) && !!input.threadId && decodedRequest._tag === "Some",
114+ enabled:
115+ (input.enabled ?? true) &&
116+ !!input.environmentId &&
117+ !!input.threadId &&
118+ decodedRequest._tag === "Some",
112119 staleTime: Infinity,
113120 retry: (failureCount, error) => {
114121 if (isCheckpointTemporarilyUnavailable(error)) {
apps/web/src/lib/terminalStateCleanup.test.ts+20−13
@@ -1,58 +1,65 @@
1+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
12 import { ThreadId } from "@t3tools/contracts";
23 import { describe, expect, it } from "vitest";
34
45 import { collectActiveTerminalThreadIds } from "./terminalStateCleanup";
56
67 const threadId = (id: string): ThreadId => ThreadId.makeUnsafe(id);
8+const threadKey = (environmentId: string, id: string): string =>
9+ scopedThreadKey(scopeThreadRef(environmentId as never, threadId(id)));
710
811 describe("collectActiveTerminalThreadIds", () => {
912 it("retains non-deleted server threads", () => {
1013 const activeThreadIds = collectActiveTerminalThreadIds({
1114 snapshotThreads: [
12− { id: threadId("server-1"), deletedAt: null, archivedAt: null },
13− { id: threadId("server-2"), deletedAt: null, archivedAt: null },
15+ { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null },
16+ { key: threadKey("env-b", "server-2"), deletedAt: null, archivedAt: null },
1417 ],
15− draftThreadIds: [],
18+ draftThreadKeys: [],
1619 });
1720
18− expect(activeThreadIds).toEqual(new Set([threadId("server-1"), threadId("server-2")]));
21+ expect(activeThreadIds).toEqual(
22+ new Set([threadKey("env-a", "server-1"), threadKey("env-b", "server-2")]),
23+ );
1924 });
2025
2126 it("ignores deleted and archived server threads and keeps local draft threads", () => {
2227 const activeThreadIds = collectActiveTerminalThreadIds({
2328 snapshotThreads: [
24− { id: threadId("server-active"), deletedAt: null, archivedAt: null },
29+ { key: threadKey("env-a", "server-active"), deletedAt: null, archivedAt: null },
2530 {
26− id: threadId("server-deleted"),
31+ key: threadKey("env-a", "server-deleted"),
2732 deletedAt: "2026-03-05T08:00:00.000Z",
2833 archivedAt: null,
2934 },
3035 {
31− id: threadId("server-archived"),
36+ key: threadKey("env-a", "server-archived"),
3237 deletedAt: null,
3338 archivedAt: "2026-03-05T09:00:00.000Z",
3439 },
3540 ],
36− draftThreadIds: [threadId("local-draft")],
41+ draftThreadKeys: [threadKey("env-a", "local-draft")],
3742 });
3843
39− expect(activeThreadIds).toEqual(new Set([threadId("server-active"), threadId("local-draft")]));
44+ expect(activeThreadIds).toEqual(
45+ new Set([threadKey("env-a", "server-active"), threadKey("env-a", "local-draft")]),
46+ );
4047 });
4148
4249 it("does not keep draft-linked terminal state for archived server threads", () => {
43− const archivedThreadId = threadId("server-archived");
50+ const archivedThreadId = threadKey("env-a", "server-archived");
4451
4552 const activeThreadIds = collectActiveTerminalThreadIds({
4653 snapshotThreads: [
4754 {
48− id: archivedThreadId,
55+ key: archivedThreadId,
4956 deletedAt: null,
5057 archivedAt: "2026-03-05T09:00:00.000Z",
5158 },
5259 ],
53− draftThreadIds: [archivedThreadId, threadId("local-draft")],
60+ draftThreadKeys: [archivedThreadId, threadKey("env-a", "local-draft")],
5461 });
5562
56− expect(activeThreadIds).toEqual(new Set([threadId("local-draft")]));
63+ expect(activeThreadIds).toEqual(new Set([threadKey("env-a", "local-draft")]));
5764 });
5865 });
apps/web/src/lib/terminalStateCleanup.ts+9−11
@@ -1,35 +1,33 @@
1−import type { ThreadId } from "@t3tools/contracts";
2−
31 interface TerminalRetentionThread {
4− id: ThreadId;
2+ key: string;
53 deletedAt: string | null;
64 archivedAt: string | null;
75 }
86
97 interface CollectActiveTerminalThreadIdsInput {
108 snapshotThreads: readonly TerminalRetentionThread[];
11− draftThreadIds: Iterable<ThreadId>;
9+ draftThreadKeys: Iterable<string>;
1210 }
1311
1412 export function collectActiveTerminalThreadIds(
1513 input: CollectActiveTerminalThreadIdsInput,
16−): Set<ThreadId> {
17− const activeThreadIds = new Set<ThreadId>();
18− const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.id, thread]));
14+): Set<string> {
15+ const activeThreadIds = new Set<string>();
16+ const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread]));
1917 for (const thread of input.snapshotThreads) {
2018 if (thread.deletedAt !== null) continue;
2119 if (thread.archivedAt !== null) continue;
22− activeThreadIds.add(thread.id);
20+ activeThreadIds.add(thread.key);
2321 }
24− for (const draftThreadId of input.draftThreadIds) {
25− const snapshotThread = snapshotThreadById.get(draftThreadId);
22+ for (const draftThreadKey of input.draftThreadKeys) {
23+ const snapshotThread = snapshotThreadById.get(draftThreadKey);
2624 if (
2725 snapshotThread &&
2826 (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null)
2927 ) {
3028 continue;
3129 }
32− activeThreadIds.add(draftThreadId);
30+ activeThreadIds.add(draftThreadKey);
3331 }
3432 return activeThreadIds;
3533 }
apps/web/src/lib/utils.test.ts+41−1
@@ -1,6 +1,15 @@
1−import { assert, describe, it } from "vitest";
1+import { assert, describe, expect, it, vi } from "vitest";
2+
3+const { resolvePrimaryEnvironmentBootstrapUrlMock } = vi.hoisted(() => ({
4+ resolvePrimaryEnvironmentBootstrapUrlMock: vi.fn(() => "http://bootstrap.test:4321"),
5+}));
6+
7+vi.mock("../environmentBootstrap", () => ({
8+ resolvePrimaryEnvironmentBootstrapUrl: resolvePrimaryEnvironmentBootstrapUrlMock,
9+}));
210
311 import { isWindowsPlatform } from "./utils";
12+import { resolveServerUrl } from "./utils";
413
514 describe("isWindowsPlatform", () => {
615 it("matches Windows platform identifiers", () => {
@@ -13,3 +22,34 @@ describe("isWindowsPlatform", () => { · 6 unmodified lines
1322 assert.isFalse(isWindowsPlatform("darwin"));
1423 });
1524 });
25+
26+describe("resolveServerUrl", () => {
27+ it("falls back to the bootstrap environment URL when the explicit URL is empty", () => {
28+ expect(resolveServerUrl({ url: "" })).toBe("http://bootstrap.test:4321/");
29+ });
30+
31+ it("uses the bootstrap environment URL when no explicit URL is provided", () => {
32+ expect(resolveServerUrl()).toBe("http://bootstrap.test:4321/");
33+ });
34+
35+ it("prefers an explicit URL override", () => {
36+ expect(
37+ resolveServerUrl({
38+ url: "https://override.test:9999",
39+ protocol: "wss",
40+ pathname: "/rpc",
41+ searchParams: { hello: "world" },
42+ }),
43+ ).toBe("wss://override.test:9999/rpc?hello=world");
44+ });
45+
46+ it("does not evaluate the bootstrap resolver when an explicit URL is provided", () => {
47+ resolvePrimaryEnvironmentBootstrapUrlMock.mockImplementationOnce(() => {
48+ throw new Error("bootstrap unavailable");
49+ });
50+
51+ expect(resolveServerUrl({ url: "https://override.test:9999" })).toBe(
52+ "https://override.test:9999/",
53+ );
54+ });
55+});
apps/web/src/lib/utils.ts+7−14
@@ -4,6 +4,8 @@ import { type CxOptions, cx } from "class-variance-authority"; · 3 unmodified lines
44 import { twMerge } from "tailwind-merge";
55 import * as Random from "effect/Random";
66 import * as Effect from "effect/Effect";
7+import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap";
8+import { DraftId } from "../composerDraftStore";
79
810 export function cn(...inputs: CxOptions) {
911 return twMerge(cx(inputs));
@@ -34,17 +36,11 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); · 24 unmodified lines
3436
3537 export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());
3638
39+export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID());
40+
3741 export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());
3842
3943 const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty);
40−const firstNonEmptyString = (...values: unknown[]): string => {
41− for (const value of values) {
42− if (isNonEmptyString(value)) {
43− return value;
44− }
45− }
46− throw new Error("No non-empty string provided");
47−};
4844
4945 export const resolveServerUrl = (options?: {
5046 url?: string | undefined;
@@ -52,12 +48,9 @@ export const resolveServerUrl = (options?: { · 1 unmodified line
5248 pathname?: string | undefined;
5349 searchParams?: Record<string, string> | undefined;
5450 }): string => {
55− const rawUrl = firstNonEmptyString(
56− options?.url,
57− window.desktopBridge?.getWsUrl(),
58− import.meta.env.VITE_WS_URL,
59− window.location.origin,
60− );
51+ const rawUrl = isNonEmptyString(options?.url)
52+ ? options.url
53+ : resolvePrimaryEnvironmentBootstrapUrl();
6154
6255 const parsedUrl = new URL(rawUrl);
6356 if (options?.protocol) {
apps/web/src/localApi.test.tsrenamed from apps/web/src/wsNativeApi.test.ts+52−25
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 CommandId,
33 DEFAULT_SERVER_SETTINGS,
44 type DesktopBridge,
5+ EnvironmentId,
56 EventId,
67 type GitStatusResult,
78 ProjectId,
@@ -94,6 +95,17 @@ const rpcClientMock = { · 86 unmodified lines
9495 vi.mock("./wsRpcClient", () => {
9596 return {
9697 getWsRpcClient: () => rpcClientMock,
98+ getPrimaryWsRpcClientEntry: () => ({
99+ key: "primary",
100+ knownEnvironment: {
101+ id: "primary",
102+ label: "Primary",
103+ source: "manual",
104+ target: { type: "ws", wsUrl: "ws://localhost:3000" },
105+ },
106+ client: rpcClientMock,
107+ environmentId: null,
108+ }),
97109 __resetWsRpcClientForTests: vi.fn(),
98110 };
99111 });
@@ -121,6 +133,7 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn · 21 unmodified lines
121133 function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {
122134 return {
123135 getWsUrl: () => null,
136+ getLocalEnvironmentBootstrap: () => null,
124137 pickFolder: async () => null,
125138 confirm: async () => true,
126139 setTheme: async () => undefined,
@@ -157,7 +170,21 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [ · 30 unmodified lines
157170 },
158171 ];
159172
173+const baseEnvironment = {
174+ environmentId: EnvironmentId.makeUnsafe("environment-local"),
175+ label: "Local environment",
176+ platform: {
177+ os: "darwin" as const,
178+ arch: "arm64" as const,
179+ },
180+ serverVersion: "0.0.0-test",
181+ capabilities: {
182+ repositoryIdentity: true,
183+ },
184+};
185+
160186 const baseServerConfig: ServerConfig = {
187+ environment: baseEnvironment,
161188 cwd: "/tmp/workspace",
162189 keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
163190 keybindings: [],
@@ -200,12 +227,12 @@ afterEach(() => { · 36 unmodified lines
200227 vi.restoreAllMocks();
201228 });
202229
203−describe("wsNativeApi", () => {
230+describe("wsApi", () => {
204231 it("forwards server config fetches directly to the RPC client", async () => {
205232 rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig);
206− const { createWsNativeApi } = await import("./wsNativeApi");
233+ const { createLocalApi } = await import("./localApi");
207234
208− const api = createWsNativeApi();
235+ const api = createLocalApi();
209236
210237 await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig);
211238 expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith();
@@ -214,9 +241,9 @@ describe("wsNativeApi", () => { · 2 unmodified lines
214241 });
215242
216243 it("forwards terminal and orchestration stream events", async () => {
217− const { createWsNativeApi } = await import("./wsNativeApi");
244+ const { createEnvironmentApi } = await import("./environmentApi");
218245
219− const api = createWsNativeApi();
246+ const api = createEnvironmentApi(rpcClientMock as never);
220247 const onTerminalEvent = vi.fn();
221248 const onDomainEvent = vi.fn();
222249
@@ -263,9 +290,9 @@ describe("wsNativeApi", () => { · 40 unmodified lines
263290 });
264291
265292 it("forwards git status stream events", async () => {
266− const { createWsNativeApi } = await import("./wsNativeApi");
293+ const { createEnvironmentApi } = await import("./environmentApi");
267294
268− const api = createWsNativeApi();
295+ const api = createEnvironmentApi(rpcClientMock as never);
269296 const onStatus = vi.fn();
270297
271298 api.git.onStatus({ cwd: "/repo" }, onStatus);
@@ -279,9 +306,9 @@ describe("wsNativeApi", () => { · 7 unmodified lines
279306
280307 it("forwards git status refreshes directly to the RPC client", async () => {
281308 rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus);
282− const { createWsNativeApi } = await import("./wsNativeApi");
309+ const { createEnvironmentApi } = await import("./environmentApi");
283310
284− const api = createWsNativeApi();
311+ const api = createEnvironmentApi(rpcClientMock as never);
285312
286313 await api.git.refreshStatus({ cwd: "/repo" });
287314
@@ -289,9 +316,9 @@ describe("wsNativeApi", () => { · 1 unmodified line
289316 });
290317
291318 it("forwards orchestration stream subscription options to the RPC client", async () => {
292− const { createWsNativeApi } = await import("./wsNativeApi");
319+ const { createEnvironmentApi } = await import("./environmentApi");
293320
294− const api = createWsNativeApi();
321+ const api = createEnvironmentApi(rpcClientMock as never);
295322 const onDomainEvent = vi.fn();
296323 const onResubscribe = vi.fn();
297324
@@ -304,9 +331,9 @@ describe("wsNativeApi", () => { · 6 unmodified lines
304331
305332 it("sends orchestration dispatch commands as the direct RPC payload", async () => {
306333 rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 });
307− const { createWsNativeApi } = await import("./wsNativeApi");
334+ const { createEnvironmentApi } = await import("./environmentApi");
308335
309− const api = createWsNativeApi();
336+ const api = createEnvironmentApi(rpcClientMock as never);
310337 const command = {
311338 type: "project.create",
312339 commandId: CommandId.makeUnsafe("cmd-1"),
@@ -326,9 +353,9 @@ describe("wsNativeApi", () => { · 13 unmodified lines
326353
327354 it("forwards workspace file writes to the project RPC", async () => {
328355 rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" });
329− const { createWsNativeApi } = await import("./wsNativeApi");
356+ const { createEnvironmentApi } = await import("./environmentApi");
330357
331− const api = createWsNativeApi();
358+ const api = createEnvironmentApi(rpcClientMock as never);
332359 await api.projects.writeFile({
333360 cwd: "/tmp/project",
334361 relativePath: "plan.md",
@@ -344,9 +371,9 @@ describe("wsNativeApi", () => { · 9 unmodified lines
344371
345372 it("forwards full-thread diff requests to the orchestration RPC", async () => {
346373 rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" });
347− const { createWsNativeApi } = await import("./wsNativeApi");
374+ const { createEnvironmentApi } = await import("./environmentApi");
348375
349− const api = createWsNativeApi();
376+ const api = createEnvironmentApi(rpcClientMock as never);
350377 await api.orchestration.getFullThreadDiff({
351378 threadId: ThreadId.makeUnsafe("thread-1"),
352379 toTurnCount: 1,
@@ -366,9 +393,9 @@ describe("wsNativeApi", () => { · 13 unmodified lines
366393 },
367394 ];
368395 rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders });
369− const { createWsNativeApi } = await import("./wsNativeApi");
396+ const { createLocalApi } = await import("./localApi");
370397
371− const api = createWsNativeApi();
398+ const api = createLocalApi();
372399
373400 await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders });
374401 expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();
@@ -380,9 +407,9 @@ describe("wsNativeApi", () => { · 5 unmodified lines
380407 enableAssistantStreaming: true,
381408 };
382409 rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings);
383− const { createWsNativeApi } = await import("./wsNativeApi");
410+ const { createLocalApi } = await import("./localApi");
384411
385− const api = createWsNativeApi();
412+ const api = createLocalApi();
386413
387414 await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual(
388415 nextSettings,
@@ -396,8 +423,8 @@ describe("wsNativeApi", () => { · 7 unmodified lines
396423 const showContextMenu = vi.fn().mockResolvedValue("delete");
397424 getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu });
398425
399− const { createWsNativeApi } = await import("./wsNativeApi");
400− const api = createWsNativeApi();
426+ const { createLocalApi } = await import("./localApi");
427+ const api = createLocalApi();
401428 const items = [{ id: "delete", label: "Delete" }] as const;
402429
403430 await expect(api.contextMenu.show(items)).resolves.toBe("delete");
@@ -406,9 +433,9 @@ describe("wsNativeApi", () => { · 2 unmodified lines
406433
407434 it("falls back to the browser context menu helper when the desktop bridge is missing", async () => {
408435 showContextMenuFallbackMock.mockResolvedValue("rename");
409− const { createWsNativeApi } = await import("./wsNativeApi");
436+ const { createLocalApi } = await import("./localApi");
410437
411− const api = createWsNativeApi();
438+ const api = createLocalApi();
412439 const items = [{ id: "rename", label: "Rename" }] as const;
413440
414441 await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename");
apps/web/src/localApi.ts+94−0
@@ -0,0 +1,94 @@
1+import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";
2+
3+import { resetGitStatusStateForTests } from "./lib/gitStatusState";
4+
5+import { __resetWsRpcAtomClientForTests } from "./rpc/client";
6+import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
7+import { resetServerStateForTests } from "./rpc/serverState";
8+import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";
9+import { getPrimaryWsRpcClientEntry, WsRpcClient, __resetWsRpcClientForTests } from "./wsRpcClient";
10+import { showContextMenuFallback } from "./contextMenuFallback";
11+
12+let cachedApi: LocalApi | undefined;
13+
14+export function createLocalApi(
15+ rpcClient: WsRpcClient = getPrimaryWsRpcClientEntry().client,
16+): LocalApi {
17+ return {
18+ dialogs: {
19+ pickFolder: async () => {
20+ if (!window.desktopBridge) return null;
21+ return window.desktopBridge.pickFolder();
22+ },
23+ confirm: async (message) => {
24+ if (window.desktopBridge) {
25+ return window.desktopBridge.confirm(message);
26+ }
27+ return window.confirm(message);
28+ },
29+ },
30+ shell: {
31+ openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }),
32+ openExternal: async (url) => {
33+ if (window.desktopBridge) {
34+ const opened = await window.desktopBridge.openExternal(url);
35+ if (!opened) {
36+ throw new Error("Unable to open link.");
37+ }
38+ return;
39+ }
40+
41+ window.open(url, "_blank", "noopener,noreferrer");
42+ },
43+ },
44+ contextMenu: {
45+ show: async <T extends string>(
46+ items: readonly ContextMenuItem<T>[],
47+ position?: { x: number; y: number },
48+ ): Promise<T | null> => {
49+ if (window.desktopBridge) {
50+ return window.desktopBridge.showContextMenu(items, position) as Promise<T | null>;
51+ }
52+ return showContextMenuFallback(items, position);
53+ },
54+ },
55+ server: {
56+ getConfig: rpcClient.server.getConfig,
57+ refreshProviders: rpcClient.server.refreshProviders,
58+ upsertKeybinding: rpcClient.server.upsertKeybinding,
59+ getSettings: rpcClient.server.getSettings,
60+ updateSettings: rpcClient.server.updateSettings,
61+ },
62+ };
63+}
64+
65+export function readLocalApi(): LocalApi | undefined {
66+ if (typeof window === "undefined") return undefined;
67+ if (cachedApi) return cachedApi;
68+
69+ if (window.nativeApi) {
70+ cachedApi = window.nativeApi;
71+ return cachedApi;
72+ }
73+
74+ cachedApi = createLocalApi();
75+ return cachedApi;
76+}
77+
78+export function ensureLocalApi(): LocalApi {
79+ const api = readLocalApi();
80+ if (!api) {
81+ throw new Error("Local API not found");
82+ }
83+ return api;
84+}
85+
86+export async function __resetLocalApiForTests() {
87+ cachedApi = undefined;
88+ await __resetWsRpcAtomClientForTests();
89+ await __resetWsRpcClientForTests();
90+ resetGitStatusStateForTests();
91+ resetRequestLatencyStateForTests();
92+ resetServerStateForTests();
93+ resetWsConnectionStateForTests();
94+}
apps/web/src/logicalProject.ts+19−0
@@ -0,0 +1,19 @@
1+import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";
2+import type { ScopedProjectRef } from "@t3tools/contracts";
3+import type { Project } from "./types";
4+
5+export function deriveLogicalProjectKey(
6+ project: Pick<Project, "environmentId" | "id" | "repositoryIdentity">,
7+): string {
8+ return (
9+ project.repositoryIdentity?.canonicalKey ??
10+ scopedProjectKey(scopeProjectRef(project.environmentId, project.id))
11+ );
12+}
13+
14+export function deriveLogicalProjectKeyFromRef(
15+ projectRef: ScopedProjectRef,
16+ project: Pick<Project, "repositoryIdentity"> | null | undefined,
17+): string {
18+ return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef);
19+}
apps/web/src/nativeApi.ts+0−31
@@ -1,31 +0,0 @@
1−import type { NativeApi } from "@t3tools/contracts";
2−
3−import { __resetWsNativeApiForTests, createWsNativeApi } from "./wsNativeApi";
4−
5−let cachedApi: NativeApi | undefined;
6−
7−export function readNativeApi(): NativeApi | undefined {
8− if (typeof window === "undefined") return undefined;
9− if (cachedApi) return cachedApi;
10−
11− if (window.nativeApi) {
12− cachedApi = window.nativeApi;
13− return cachedApi;
14− }
15−
16− cachedApi = createWsNativeApi();
17− return cachedApi;
18−}
19−
20−export function ensureNativeApi(): NativeApi {
21− const api = readNativeApi();
22− if (!api) {
23− throw new Error("Native API not found");
24− }
25− return api;
26−}
27−
28−export async function __resetNativeApiForTests() {
29− cachedApi = undefined;
30− await __resetWsNativeApiForTests();
31−}
apps/web/src/orchestrationEventEffects.test.ts+3−3
@@ -68,7 +68,7 @@ describe("deriveOrchestrationBatchEffects", () => { · 67 unmodified lines
6868 }),
6969 ]);
7070
71− expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]);
71+ expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]);
7272 expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]);
7373 expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]);
7474 expect(effects.needsProviderInvalidation).toBe(false);
@@ -106,7 +106,7 @@ describe("deriveOrchestrationBatchEffects", () => { · 31 unmodified lines
106106 }),
107107 ]);
108108
109− expect(effects.clearPromotedDraftThreadIds).toEqual([threadId]);
109+ expect(effects.promoteDraftThreadIds).toEqual([threadId]);
110110 expect(effects.clearDeletedThreadIds).toEqual([]);
111111 expect(effects.removeTerminalStateThreadIds).toEqual([]);
112112 expect(effects.needsProviderInvalidation).toBe(true);
@@ -127,7 +127,7 @@ describe("deriveOrchestrationBatchEffects", () => { · 14 unmodified lines
127127 }),
128128 ]);
129129
130− expect(effects.clearPromotedDraftThreadIds).toEqual([]);
130+ expect(effects.promoteDraftThreadIds).toEqual([]);
131131 expect(effects.clearDeletedThreadIds).toEqual([]);
132132 expect(effects.removeTerminalStateThreadIds).toEqual([]);
133133 });
apps/web/src/orchestrationEventEffects.ts+4−4
@@ -1,7 +1,7 @@
11 import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts";
22
33 export interface OrchestrationBatchEffects {
4− clearPromotedDraftThreadIds: ThreadId[];
4+ promoteDraftThreadIds: ThreadId[];
55 clearDeletedThreadIds: ThreadId[];
66 removeTerminalStateThreadIds: ThreadId[];
77 needsProviderInvalidation: boolean;
@@ -70,12 +70,12 @@ export function deriveOrchestrationBatchEffects( · 62 unmodified lines
7070 }
7171 }
7272
73− const clearPromotedDraftThreadIds: ThreadId[] = [];
73+ const promoteDraftThreadIds: ThreadId[] = [];
7474 const clearDeletedThreadIds: ThreadId[] = [];
7575 const removeTerminalStateThreadIds: ThreadId[] = [];
7676 for (const [threadId, effect] of threadLifecycleEffects) {
7777 if (effect.clearPromotedDraft) {
78− clearPromotedDraftThreadIds.push(threadId);
78+ promoteDraftThreadIds.push(threadId);
7979 }
8080 if (effect.clearDeletedThread) {
8181 clearDeletedThreadIds.push(threadId);
@@ -86,7 +86,7 @@ export function deriveOrchestrationBatchEffects( · 4 unmodified lines
8686 }
8787
8888 return {
89− clearPromotedDraftThreadIds,
89+ promoteDraftThreadIds,
9090 clearDeletedThreadIds,
9191 removeTerminalStateThreadIds,
9292 needsProviderInvalidation,
apps/web/src/routeTree.gen.ts+39−17
@@ -14,7 +14,8 @@ import { Route as ChatRouteImport } from './routes/_chat' · 13 unmodified lines
1414 import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1515 import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
1616 import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
17−import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId'
17+import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
18+import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
1819
1920 const SettingsRoute = SettingsRouteImport.update({
2021 id: '/settings',
@@ -40,58 +41,70 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ · 19 unmodified lines
4041 path: '/archived',
4142 getParentRoute: () => SettingsRoute,
4243 } as any)
43−const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
44− id: '/$threadId',
45− path: '/$threadId',
44+const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({
45+ id: '/draft/$draftId',
46+ path: '/draft/$draftId',
4647 getParentRoute: () => ChatRoute,
4748 } as any)
49+const ChatEnvironmentIdThreadIdRoute =
50+ ChatEnvironmentIdThreadIdRouteImport.update({
51+ id: '/$environmentId/$threadId',
52+ path: '/$environmentId/$threadId',
53+ getParentRoute: () => ChatRoute,
54+ } as any)
4855
4956 export interface FileRoutesByFullPath {
5057 '/': typeof ChatIndexRoute
5158 '/settings': typeof SettingsRouteWithChildren
52− '/$threadId': typeof ChatThreadIdRoute
5359 '/settings/archived': typeof SettingsArchivedRoute
5460 '/settings/general': typeof SettingsGeneralRoute
61+ '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
62+ '/draft/$draftId': typeof ChatDraftDraftIdRoute
5563 }
5664 export interface FileRoutesByTo {
5765 '/settings': typeof SettingsRouteWithChildren
58− '/$threadId': typeof ChatThreadIdRoute
5966 '/settings/archived': typeof SettingsArchivedRoute
6067 '/settings/general': typeof SettingsGeneralRoute
6168 '/': typeof ChatIndexRoute
69+ '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
70+ '/draft/$draftId': typeof ChatDraftDraftIdRoute
6271 }
6372 export interface FileRoutesById {
6473 __root__: typeof rootRouteImport
6574 '/_chat': typeof ChatRouteWithChildren
6675 '/settings': typeof SettingsRouteWithChildren
67− '/_chat/$threadId': typeof ChatThreadIdRoute
6876 '/settings/archived': typeof SettingsArchivedRoute
6977 '/settings/general': typeof SettingsGeneralRoute
7078 '/_chat/': typeof ChatIndexRoute
79+ '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
80+ '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute
7181 }
7282 export interface FileRouteTypes {
7383 fileRoutesByFullPath: FileRoutesByFullPath
7484 fullPaths:
7585 | '/'
7686 | '/settings'
77− | '/$threadId'
7887 | '/settings/archived'
7988 | '/settings/general'
89+ | '/$environmentId/$threadId'
90+ | '/draft/$draftId'
8091 fileRoutesByTo: FileRoutesByTo
8192 to:
8293 | '/settings'
83− | '/$threadId'
8494 | '/settings/archived'
8595 | '/settings/general'
8696 | '/'
97+ | '/$environmentId/$threadId'
98+ | '/draft/$draftId'
8799 id:
88100 | '__root__'
89101 | '/_chat'
90102 | '/settings'
91− | '/_chat/$threadId'
92103 | '/settings/archived'
93104 | '/settings/general'
94105 | '/_chat/'
106+ | '/_chat/$environmentId/$threadId'
107+ | '/_chat/draft/$draftId'
95108 fileRoutesById: FileRoutesById
96109 }
97110 export interface RootRouteChildren {
@@ -136,24 +149,33 @@ declare module '@tanstack/react-router' { · 38 unmodified lines
136149 preLoaderRoute: typeof SettingsArchivedRouteImport
137150 parentRoute: typeof SettingsRoute
138151 }
139− '/_chat/$threadId': {
140− id: '/_chat/$threadId'
141− path: '/$threadId'
142− fullPath: '/$threadId'
143− preLoaderRoute: typeof ChatThreadIdRouteImport
152+ '/_chat/draft/$draftId': {
153+ id: '/_chat/draft/$draftId'
154+ path: '/draft/$draftId'
155+ fullPath: '/draft/$draftId'
156+ preLoaderRoute: typeof ChatDraftDraftIdRouteImport
157+ parentRoute: typeof ChatRoute
158+ }
159+ '/_chat/$environmentId/$threadId': {
160+ id: '/_chat/$environmentId/$threadId'
161+ path: '/$environmentId/$threadId'
162+ fullPath: '/$environmentId/$threadId'
163+ preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport
144164 parentRoute: typeof ChatRoute
145165 }
146166 }
147167 }
148168
149169 interface ChatRouteChildren {
150− ChatThreadIdRoute: typeof ChatThreadIdRoute
151170 ChatIndexRoute: typeof ChatIndexRoute
171+ ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute
172+ ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute
152173 }
153174
154175 const ChatRouteChildren: ChatRouteChildren = {
155− ChatThreadIdRoute: ChatThreadIdRoute,
156176 ChatIndexRoute: ChatIndexRoute,
177+ ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute,
178+ ChatDraftDraftIdRoute: ChatDraftDraftIdRoute,
157179 }
158180
159181 const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
apps/web/src/routes/__root.tsx+319−166
@@ -1,8 +1,15 @@
11 import {
2+ type EnvironmentId,
23 OrchestrationEvent,
34 type ServerLifecycleWelcomePayload,
45 ThreadId,
56 } from "@t3tools/contracts";
7+import {
8+ scopedProjectKey,
9+ scopedThreadKey,
10+ scopeProjectRef,
11+ scopeThreadRef,
12+} from "@t3tools/client-runtime";
613 import {
714 Outlet,
815 createRootRouteWithContext,
@@ -10,7 +17,7 @@ import { · 1 unmodified line
1017 useNavigate,
1118 useLocation,
1219 } from "@tanstack/react-router";
13−import { useEffect, useEffectEvent, useRef } from "react";
20+import { useEffect, useEffectEvent, useRef, useState } from "react";
1421 import { QueryClient, useQueryClient } from "@tanstack/react-query";
1522 import { Throttler } from "@tanstack/react-pacer";
1623
@@ -24,7 +31,7 @@ import { · 7 unmodified lines
2431 import { Button } from "../components/ui/button";
2532 import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
2633 import { resolveAndPersistPreferredEditor } from "../editorPreferences";
27−import { readNativeApi } from "../nativeApi";
34+import { readLocalApi } from "../localApi";
2835 import {
2936 getServerConfigUpdatedNotification,
3037 ServerConfigUpdatedNotification,
@@ -34,11 +41,15 @@ import { · 3 unmodified lines
3441 useServerWelcomeSubscription,
3542 } from "../rpc/serverState";
3643 import {
37− clearPromotedDraftThread,
38− clearPromotedDraftThreads,
44+ markPromotedDraftThreadByRef,
45+ markPromotedDraftThreadsByRef,
3946 useComposerDraftStore,
4047 } from "../composerDraftStore";
41−import { selectProjects, selectThreadById, selectThreads, useStore } from "../store";
48+import {
49+ selectProjectsAcrossEnvironments,
50+ selectThreadsAcrossEnvironments,
51+ useStore,
52+} from "../store";
4253 import { useUiStateStore } from "../uiStateStore";
4354 import { useTerminalStateStore } from "../terminalStateStore";
4455 import { migrateLocalSettingsToServer } from "../hooks/useSettings";
@@ -48,7 +59,15 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; · 3 unmodified lines
4859 import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";
4960 import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";
5061 import { deriveReplayRetryDecision } from "../orchestrationRecovery";
51−import { getWsRpcClient } from "~/wsRpcClient";
62+import { selectThreadByRef } from "../store";
63+import {
64+ bindPrimaryWsRpcClientEnvironment,
65+ bindWsRpcClientEntryEnvironment,
66+ getPrimaryWsRpcClientEntry,
67+ listWsRpcClientEntries,
68+ subscribeWsRpcClientRegistry,
69+ type WsRpcClientEntry,
70+} from "~/wsRpcClient";
5271
5372 export const Route = createRootRouteWithContext<{
5473 queryClient: QueryClient;
@@ -61,7 +80,7 @@ export const Route = createRootRouteWithContext<{ · 6 unmodified lines
6180 });
6281
6382 function RootRouteView() {
64− if (!readNativeApi()) {
83+ if (!readLocalApi()) {
6584 return (
6685 <div className="flex h-screen flex-col bg-background text-foreground">
6786 <div className="flex flex-1 items-center justify-center">
@@ -201,14 +220,24 @@ function coalesceOrchestrationUiEvents( · 133 unmodified lines
201220 const REPLAY_RECOVERY_RETRY_DELAY_MS = 100;
202221 const MAX_NO_PROGRESS_REPLAY_RETRIES = 3;
203222
223+function useRegisteredWsRpcClientEntries(): ReadonlyArray<WsRpcClientEntry> {
224+ const [, setRevision] = useState(0);
225+
226+ useEffect(() => subscribeWsRpcClientRegistry(() => setRevision((value) => value + 1)), []);
227+
228+ const entries = listWsRpcClientEntries();
229+ return entries.length > 0 ? entries : [getPrimaryWsRpcClientEntry()];
230+}
231+
204232 function ServerStateBootstrap() {
205− useEffect(() => startServerStateSync(getWsRpcClient().server), []);
233+ useEffect(() => startServerStateSync(getPrimaryWsRpcClientEntry().client.server), []);
206234
207235 return null;
208236 }
209237
210238 function EventRouter() {
211239 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);
240+ const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId);
212241 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
213242 const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded);
214243 const syncProjects = useUiStateStore((store) => store.syncProjects);
@@ -226,15 +255,22 @@ function EventRouter() { · 11 unmodified lines
226255 const handledBootstrapThreadIdRef = useRef<string | null>(null);
227256 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);
228257 const disposedRef = useRef(false);
229− const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined);
258+ const bootstrapFromSnapshotRef = useRef<(environmentId: EnvironmentId) => Promise<void>>(
259+ async () => undefined,
260+ );
261+ const schedulePendingDomainEventFlushRef = useRef<() => void>(() => undefined);
230262 const serverConfig = useServerConfig();
263+ const clientEntries = useRegisteredWsRpcClientEntries();
231264
232265 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {
233266 if (!payload) return;
234267
268+ bindPrimaryWsRpcClientEnvironment(payload.environment.environmentId);
269+ setActiveEnvironmentId(payload.environment.environmentId);
270+ schedulePendingDomainEventFlushRef.current();
235271 migrateLocalSettingsToServer();
236272 void (async () => {
237− await bootstrapFromSnapshotRef.current();
273+ await bootstrapFromSnapshotRef.current(payload.environment.environmentId);
238274 if (disposedRef.current) {
239275 return;
240276 }
@@ -242,7 +278,12 @@ function EventRouter() { · 1 unmodified line
242278 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
243279 return;
244280 }
245− setProjectExpanded(payload.bootstrapProjectId, true);
281+ setProjectExpanded(
282+ scopedProjectKey(
283+ scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),
284+ ),
285+ true,
286+ );
246287
247288 if (readPathname() !== "/") {
248289 return;
@@ -251,8 +292,11 @@ function EventRouter() { · 2 unmodified lines
251292 return;
252293 }
253294 await navigate({
254− to: "/$threadId",
255− params: { threadId: payload.bootstrapThreadId },
295+ to: "/$environmentId/$threadId",
296+ params: {
297+ environmentId: payload.environment.environmentId,
298+ threadId: payload.bootstrapThreadId,
299+ },
256300 replace: true,
257301 });
258302 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
@@ -289,7 +333,7 @@ function EventRouter() { · 30 unmodified lines
289333 actionProps: {
290334 children: "Open keybindings.json",
291335 onClick: () => {
292− const api = readNativeApi();
336+ const api = readLocalApi();
293337 if (!api) {
294338 return;
295339 }
@@ -317,40 +361,49 @@ function EventRouter() { · 21 unmodified lines
317361 );
318362
319363 useEffect(() => {
320− const api = readNativeApi();
321− if (!api) return;
364+ if (!serverConfig) {
365+ return;
366+ }
367+
368+ bindPrimaryWsRpcClientEnvironment(serverConfig.environment.environmentId);
369+ setActiveEnvironmentId(serverConfig.environment.environmentId);
370+ schedulePendingDomainEventFlushRef.current();
371+ }, [serverConfig, setActiveEnvironmentId]);
372+
373+ useEffect(() => {
322374 let disposed = false;
323375 disposedRef.current = false;
324− const recovery = createOrchestrationRecoveryCoordinator();
325− let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null;
326376 let needsProviderInvalidation = false;
327− const pendingDomainEvents: OrchestrationEvent[] = [];
328− let flushPendingDomainEventsScheduled = false;
377+ const primaryClientKey = getPrimaryWsRpcClientEntry().key;
329378
330379 const reconcileSnapshotDerivedState = () => {
331380 const storeState = useStore.getState();
332− const threads = selectThreads(storeState);
333− const projects = selectProjects(storeState);
334− syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));
381+ const threads = selectThreadsAcrossEnvironments(storeState);
382+ const projects = selectProjectsAcrossEnvironments(storeState);
383+ syncProjects(
384+ projects.map((project) => ({
385+ key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
386+ cwd: project.cwd,
387+ })),
388+ );
335389 syncThreads(
336390 threads.map((thread) => ({
337− id: thread.id,
391+ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
338392 seedVisitedAt: thread.updatedAt ?? thread.createdAt,
339393 })),
340394 );
341− clearPromotedDraftThreads(threads.map((thread) => thread.id));
342− const draftThreadIds = Object.keys(
343− useComposerDraftStore.getState().draftThreadsByThreadId,
344− ) as ThreadId[];
345− const activeThreadIds = collectActiveTerminalThreadIds({
395+ markPromotedDraftThreadsByRef(
396+ threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)),
397+ );
398+ const activeThreadKeys = collectActiveTerminalThreadIds({
346399 snapshotThreads: threads.map((thread) => ({
347− id: thread.id,
400+ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
348401 deletedAt: null,
349402 archivedAt: thread.archivedAt,
350403 })),
351− draftThreadIds,
404+ draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(),
352405 });
353− removeOrphanedTerminalStates(activeThreadIds);
406+ removeOrphanedTerminalStates(activeThreadKeys);
354407 };
355408
356409 const queryInvalidationThrottler = new Throttler(
@@ -371,7 +424,11 @@ function EventRouter() { · 14 unmodified lines
371424 },
372425 );
373426
374− const applyEventBatch = (events: ReadonlyArray<OrchestrationEvent>) => {
427+ const applyEventBatch = (
428+ events: ReadonlyArray<OrchestrationEvent>,
429+ environmentId: EnvironmentId,
430+ recovery: ReturnType<typeof createOrchestrationRecoveryCoordinator>,
431+ ) => {
375432 const nextEvents = recovery.markEventBatchApplied(events);
376433 if (nextEvents.length === 0) {
377434 return;
@@ -391,189 +448,284 @@ function EventRouter() { · 13 unmodified lines
391448 void queryInvalidationThrottler.maybeExecute();
392449 }
393450
394− applyOrchestrationEvents(uiEvents);
451+ applyOrchestrationEvents(uiEvents, environmentId);
395452 if (needsProjectUiSync) {
396− const projects = selectProjects(useStore.getState());
397− syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));
453+ const projects = selectProjectsAcrossEnvironments(useStore.getState());
454+ syncProjects(
455+ projects.map((project) => ({
456+ key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
457+ cwd: project.cwd,
458+ })),
459+ );
398460 }
399461 const needsThreadUiSync = nextEvents.some(
400462 (event) => event.type === "thread.created" || event.type === "thread.deleted",
401463 );
402464 if (needsThreadUiSync) {
403− const threads = selectThreads(useStore.getState());
465+ const threads = selectThreadsAcrossEnvironments(useStore.getState());
404466 syncThreads(
405467 threads.map((thread) => ({
406− id: thread.id,
468+ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
407469 seedVisitedAt: thread.updatedAt ?? thread.createdAt,
408470 })),
409471 );
410472 }
411473 const draftStore = useComposerDraftStore.getState();
412− for (const threadId of batchEffects.clearPromotedDraftThreadIds) {
413− clearPromotedDraftThread(threadId);
474+ for (const threadId of batchEffects.promoteDraftThreadIds) {
475+ markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId));
414476 }
415477 for (const threadId of batchEffects.clearDeletedThreadIds) {
416− draftStore.clearDraftThread(threadId);
417− clearThreadUi(threadId);
478+ draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId));
479+ clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId)));
418480 }
419481 for (const threadId of batchEffects.removeTerminalStateThreadIds) {
420− removeTerminalState(threadId);
482+ removeTerminalState(scopeThreadRef(environmentId, threadId));
421483 }
422484 };
423− const flushPendingDomainEvents = () => {
424− flushPendingDomainEventsScheduled = false;
425− if (disposed || pendingDomainEvents.length === 0) {
426− return;
427− }
485+ const clientContexts = clientEntries.map((entry) => {
486+ const recovery = createOrchestrationRecoveryCoordinator();
487+ let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null;
488+ const pendingDomainEvents: OrchestrationEvent[] = [];
489+ let flushPendingDomainEventsScheduled = false;
490+ let boundEnvironmentId = entry.environmentId;
491+
492+ const bindEnvironmentId = (environmentId: EnvironmentId) => {
493+ if (boundEnvironmentId === environmentId) {
494+ return;
495+ }
496+ boundEnvironmentId = environmentId;
497+ bindWsRpcClientEntryEnvironment(entry.key, environmentId);
498+ schedulePendingDomainEventFlush();
499+ };
428500
429− const events = pendingDomainEvents.splice(0, pendingDomainEvents.length);
430− applyEventBatch(events);
431− };
432− const schedulePendingDomainEventFlush = () => {
433− if (flushPendingDomainEventsScheduled) {
434− return;
435− }
501+ const flushPendingDomainEvents = () => {
502+ flushPendingDomainEventsScheduled = false;
503+ if (disposed || pendingDomainEvents.length === 0 || boundEnvironmentId === null) {
504+ return;
505+ }
436506
437− flushPendingDomainEventsScheduled = true;
438− queueMicrotask(flushPendingDomainEvents);
439− };
507+ const events = pendingDomainEvents.splice(0, pendingDomainEvents.length);
508+ applyEventBatch(events, boundEnvironmentId, recovery);
509+ };
440510
441− const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => {
442− if (!recovery.beginReplayRecovery(reason)) {
443− return;
444− }
511+ const schedulePendingDomainEventFlush = () => {
512+ if (flushPendingDomainEventsScheduled) {
513+ return;
514+ }
445515
446− const fromSequenceExclusive = recovery.getState().latestSequence;
447− try {
448− const events = await api.orchestration.replayEvents(fromSequenceExclusive);
449− if (!disposed) {
450− applyEventBatch(events);
516+ flushPendingDomainEventsScheduled = true;
517+ queueMicrotask(flushPendingDomainEvents);
518+ };
519+
520+ const runSnapshotRecovery = async (
521+ reason: "bootstrap" | "replay-failed",
522+ environmentId: EnvironmentId,
523+ ): Promise<void> => {
524+ const started = recovery.beginSnapshotRecovery(reason);
525+ if (import.meta.env.MODE !== "test") {
526+ const state = recovery.getState();
527+ console.info("[orchestration-recovery]", "Snapshot recovery requested.", {
528+ reason,
529+ clientKey: entry.key,
530+ environmentId,
531+ skipped: !started,
532+ ...(started
533+ ? {}
534+ : {
535+ blockedBy: state.inFlight?.kind ?? null,
536+ blockedByReason: state.inFlight?.reason ?? null,
537+ }),
538+ state,
539+ });
540+ }
541+ if (!started) {
542+ return;
451543 }
452− } catch {
453− replayRetryTracker = null;
454− recovery.failReplayRecovery();
455− void fallbackToSnapshotRecovery();
456− return;
457− }
458544
459− if (!disposed) {
460− const replayCompletion = recovery.completeReplayRecovery();
461− const retryDecision = deriveReplayRetryDecision({
462− previousTracker: replayRetryTracker,
463− completion: replayCompletion,
464− recoveryState: recovery.getState(),
465− baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS,
466− maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES,
467− });
468− replayRetryTracker = retryDecision.tracker;
545+ try {
546+ const snapshot = await entry.client.orchestration.getSnapshot();
547+ if (!disposed) {
548+ bindEnvironmentId(environmentId);
549+ syncServerReadModel(snapshot, environmentId);
550+ reconcileSnapshotDerivedState();
551+ if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {
552+ void runReplayRecovery("sequence-gap");
553+ }
554+ }
555+ } catch {
556+ recovery.failSnapshotRecovery();
557+ }
558+ };
469559
470− if (retryDecision.shouldRetry) {
471− if (retryDecision.delayMs > 0) {
472− await new Promise<void>((resolve) => {
473− setTimeout(resolve, retryDecision.delayMs);
474− });
475− if (disposed) {
560+ const fallbackToSnapshotRecovery = async (): Promise<void> => {
561+ if (boundEnvironmentId === null) {
562+ return;
563+ }
564+ await runSnapshotRecovery("replay-failed", boundEnvironmentId);
565+ };
566+
567+ const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => {
568+ if (!recovery.beginReplayRecovery(reason)) {
569+ return;
570+ }
571+
572+ const fromSequenceExclusive = recovery.getState().latestSequence;
573+ try {
574+ const events = await entry.client.orchestration.replayEvents({ fromSequenceExclusive });
575+ if (!disposed) {
576+ if (boundEnvironmentId === null) {
577+ replayRetryTracker = null;
578+ recovery.failReplayRecovery();
476579 return;
477580 }
581+ applyEventBatch(events, boundEnvironmentId, recovery);
478582 }
479− void runReplayRecovery(reason);
480− } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") {
481− console.warn(
482− "[orchestration-recovery]",
483− "Stopping replay recovery after no-progress retries.",
484− {
485− state: recovery.getState(),
486− },
487− );
583+ } catch {
584+ replayRetryTracker = null;
585+ recovery.failReplayRecovery();
586+ void fallbackToSnapshotRecovery();
587+ return;
488588 }
489− }
490− };
491−
492− const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise<void> => {
493− const started = recovery.beginSnapshotRecovery(reason);
494− if (import.meta.env.MODE !== "test") {
495− const state = recovery.getState();
496− console.info("[orchestration-recovery]", "Snapshot recovery requested.", {
497− reason,
498− skipped: !started,
499− ...(started
500− ? {}
501− : {
502− blockedBy: state.inFlight?.kind ?? null,
503− blockedByReason: state.inFlight?.reason ?? null,
504− }),
505− state,
506− });
507− }
508− if (!started) {
509− return;
510− }
511589
512− try {
513− const snapshot = await api.orchestration.getSnapshot();
514590 if (!disposed) {
515− syncServerReadModel(snapshot);
516− reconcileSnapshotDerivedState();
517− if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {
518− void runReplayRecovery("sequence-gap");
591+ const replayCompletion = recovery.completeReplayRecovery();
592+ const retryDecision = deriveReplayRetryDecision({
593+ previousTracker: replayRetryTracker,
594+ completion: replayCompletion,
595+ recoveryState: recovery.getState(),
596+ baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS,
597+ maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES,
598+ });
599+ replayRetryTracker = retryDecision.tracker;
600+
601+ if (retryDecision.shouldRetry) {
602+ if (retryDecision.delayMs > 0) {
603+ await new Promise<void>((resolve) => {
604+ setTimeout(resolve, retryDecision.delayMs);
605+ });
606+ if (disposed) {
607+ return;
608+ }
609+ }
610+ void runReplayRecovery(reason);
611+ } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") {
612+ console.warn(
613+ "[orchestration-recovery]",
614+ "Stopping replay recovery after no-progress retries.",
615+ {
616+ clientKey: entry.key,
617+ environmentId: boundEnvironmentId,
618+ state: recovery.getState(),
619+ },
620+ );
519621 }
520622 }
521− } catch {
522− // Keep prior state and wait for welcome or a later replay attempt.
523− recovery.failSnapshotRecovery();
524− }
525− };
526−
527− const bootstrapFromSnapshot = async (): Promise<void> => {
528− await runSnapshotRecovery("bootstrap");
529− };
530− bootstrapFromSnapshotRef.current = bootstrapFromSnapshot;
623+ };
531624
532− const fallbackToSnapshotRecovery = async (): Promise<void> => {
533− await runSnapshotRecovery("replay-failed");
534− };
535− const unsubDomainEvent = api.orchestration.onDomainEvent(
536− (event) => {
537− const action = recovery.classifyDomainEvent(event.sequence);
538− if (action === "apply") {
539− pendingDomainEvents.push(event);
540− schedulePendingDomainEventFlush();
541− return;
625+ const unsubLifecycle = entry.client.server.subscribeLifecycle((event) => {
626+ if (event.type === "welcome") {
627+ bindEnvironmentId(event.payload.environment.environmentId);
542628 }
543− if (action === "recover") {
544− flushPendingDomainEvents();
545− void runReplayRecovery("sequence-gap");
629+ });
630+ const unsubConfig = entry.client.server.subscribeConfig((event) => {
631+ if (event.type === "snapshot") {
632+ bindEnvironmentId(event.config.environment.environmentId);
546633 }
547− },
548− {
549− onResubscribe: () => {
550− if (disposed) {
634+ });
635+ if (boundEnvironmentId === null) {
636+ void entry.client.server
637+ .getConfig()
638+ .then((config) => {
639+ if (!disposed) {
640+ bindEnvironmentId(config.environment.environmentId);
641+ }
642+ })
643+ .catch(() => undefined);
644+ }
645+ const unsubDomainEvent = entry.client.orchestration.onDomainEvent(
646+ (event) => {
647+ const action = recovery.classifyDomainEvent(event.sequence);
648+ if (action === "apply") {
649+ pendingDomainEvents.push(event);
650+ schedulePendingDomainEventFlush();
551651 return;
552652 }
553− flushPendingDomainEvents();
554− void runReplayRecovery("resubscribe");
653+ if (action === "recover") {
654+ flushPendingDomainEvents();
655+ void runReplayRecovery("sequence-gap");
656+ }
555657 },
556− },
557− );
558− const unsubTerminalEvent = api.terminal.onEvent((event) => {
559− const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState());
560− if (thread && thread.archivedAt !== null) {
658+ {
659+ onResubscribe: () => {
660+ if (disposed) {
661+ return;
662+ }
663+ flushPendingDomainEvents();
664+ void runReplayRecovery("resubscribe");
665+ },
666+ },
667+ );
668+ const unsubTerminalEvent = entry.client.terminal.onEvent((event) => {
669+ if (boundEnvironmentId === null) {
670+ return;
671+ }
672+
673+ const threadRef = scopeThreadRef(boundEnvironmentId, ThreadId.makeUnsafe(event.threadId));
674+ const thread = selectThreadByRef(useStore.getState(), threadRef);
675+ if (!thread || thread.archivedAt !== null) {
676+ return;
677+ }
678+ applyTerminalEvent(threadRef, event);
679+ });
680+
681+ return {
682+ key: entry.key,
683+ bindEnvironmentId,
684+ flushPendingDomainEvents,
685+ schedulePendingDomainEventFlush,
686+ runSnapshotRecovery,
687+ cleanup: () => {
688+ flushPendingDomainEventsScheduled = false;
689+ pendingDomainEvents.length = 0;
690+ unsubDomainEvent();
691+ unsubTerminalEvent();
692+ unsubLifecycle();
693+ unsubConfig();
694+ },
695+ };
696+ });
697+
698+ schedulePendingDomainEventFlushRef.current = () => {
699+ for (const context of clientContexts) {
700+ context.schedulePendingDomainEventFlush();
701+ }
702+ };
703+
704+ const primaryClientContext =
705+ clientContexts.find((context) => context.key === primaryClientKey) ??
706+ clientContexts[0] ??
707+ null;
708+ bootstrapFromSnapshotRef.current = async (environmentId: EnvironmentId) => {
709+ if (!primaryClientContext) {
561710 return;
562711 }
563− applyTerminalEvent(event);
564− });
712+ primaryClientContext.bindEnvironmentId(environmentId);
713+ await primaryClientContext.runSnapshotRecovery("bootstrap", environmentId);
714+ };
715+
565716 return () => {
566717 disposed = true;
567718 disposedRef.current = true;
568719 needsProviderInvalidation = false;
569− flushPendingDomainEventsScheduled = false;
570− pendingDomainEvents.length = 0;
720+ schedulePendingDomainEventFlushRef.current = () => undefined;
571721 queryInvalidationThrottler.cancel();
572− unsubDomainEvent();
573− unsubTerminalEvent();
722+ for (const context of clientContexts) {
723+ context.cleanup();
724+ }
574725 };
575726 }, [
576727 applyOrchestrationEvents,
728+ clientEntries,
577729 navigate,
578730 queryClient,
579731 removeTerminalState,
@@ -581,6 +733,7 @@ function EventRouter() { · 1 unmodified line
581733 applyTerminalEvent,
582734 clearThreadUi,
583735 setProjectExpanded,
736+ setActiveEnvironmentId,
584737 syncProjects,
585738 syncServerReadModel,
586739 syncThreads,
apps/web/src/routes/_chat.$environmentId.$threadId.tsxrenamed from apps/web/src/routes/_chat.$threadId.tsx+61−24
@@ -1,8 +1,8 @@
1−import { ThreadId } from "@t3tools/contracts";
21 import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";
3−import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react";
2+import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
43
54 import ChatView from "../components/ChatView";
5+import { threadHasStarted } from "../components/ChatView.logic";
66 import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
77 import {
88 DiffPanelHeaderSkeleton,
@@ -10,14 +10,16 @@ import { · 1 unmodified line
1010 DiffPanelShell,
1111 type DiffPanelMode,
1212 } from "../components/DiffPanelShell";
13−import { useComposerDraftStore } from "../composerDraftStore";
13+import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
1414 import {
1515 type DiffRouteSearch,
1616 parseDiffRouteSearch,
1717 stripDiffSearchParams,
1818 } from "../diffRouteSearch";
1919 import { useMediaQuery } from "../hooks/useMediaQuery";
20−import { useStore } from "../store";
20+import { selectEnvironmentState, selectThreadByRef, useStore } from "../store";
21+import { createThreadSelectorByRef } from "../storeSelectors";
22+import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";
2123 import { Sheet, SheetPopup } from "../components/ui/sheet";
2224 import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
2325
@@ -161,39 +163,60 @@ const DiffPanelInlineSidebar = (props: { · 137 unmodified lines
161163 };
162164
163165 function ChatThreadRouteView() {
164− const bootstrapComplete = useStore((store) => store.bootstrapComplete);
165166 const navigate = useNavigate();
166− const threadId = Route.useParams({
167− select: (params) => ThreadId.makeUnsafe(params.threadId),
167+ const threadRef = Route.useParams({
168+ select: (params) => resolveThreadRouteRef(params),
168169 });
169170 const search = Route.useSearch();
170− const threadExists = useStore((store) => store.threadShellById[threadId] !== undefined);
171+ const bootstrapComplete = useStore(
172+ (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete,
173+ );
174+ const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
175+ const threadExists = useStore((store) => selectThreadByRef(store, threadRef) !== undefined);
176+ const environmentHasServerThreads = useStore(
177+ (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0,
178+ );
171179 const draftThreadExists = useComposerDraftStore((store) =>
172− Object.hasOwn(store.draftThreadsByThreadId, threadId),
180+ threadRef ? store.getDraftThreadByRef(threadRef) !== null : false,
173181 );
182+ const draftThread = useComposerDraftStore((store) =>
183+ threadRef ? store.getDraftThreadByRef(threadRef) : null,
184+ );
185+ const environmentHasDraftThreads = useComposerDraftStore((store) => {
186+ if (!threadRef) {
187+ return false;
188+ }
189+ return store.hasDraftThreadsInEnvironment(threadRef.environmentId);
190+ });
174191 const routeThreadExists = threadExists || draftThreadExists;
192+ const serverThreadStarted = threadHasStarted(serverThread);
193+ const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
175194 const diffOpen = search.diff === "1";
176195 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
177− // TanStack Router keeps active route components mounted across param-only navigations
178− // unless remountDeps are configured, so this stays warm across thread switches.
179196 const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen);
180197 const closeDiff = useCallback(() => {
198+ if (!threadRef) {
199+ return;
200+ }
181201 void navigate({
182− to: "/$threadId",
183− params: { threadId },
202+ to: "/$environmentId/$threadId",
203+ params: buildThreadRouteParams(threadRef),
184204 search: { diff: undefined },
185205 });
186− }, [navigate, threadId]);
206+ }, [navigate, threadRef]);
187207 const openDiff = useCallback(() => {
208+ if (!threadRef) {
209+ return;
210+ }
188211 void navigate({
189− to: "/$threadId",
190− params: { threadId },
212+ to: "/$environmentId/$threadId",
213+ params: buildThreadRouteParams(threadRef),
191214 search: (previous) => {
192215 const rest = stripDiffSearchParams(previous);
193216 return { ...rest, diff: "1" };
194217 },
195218 });
196− }, [navigate, threadId]);
219+ }, [navigate, threadRef]);
197220
198221 useEffect(() => {
199222 if (diffOpen) {
@@ -202,17 +225,23 @@ function ChatThreadRouteView() { · 2 unmodified lines
202225 }, [diffOpen]);
203226
204227 useEffect(() => {
205− if (!bootstrapComplete) {
228+ if (!threadRef || !bootstrapComplete) {
206229 return;
207230 }
208231
209− if (!routeThreadExists) {
232+ if (!routeThreadExists && environmentHasAnyThreads) {
210233 void navigate({ to: "/", replace: true });
234+ }
235+ }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]);
236+
237+ useEffect(() => {
238+ if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) {
211239 return;
212240 }
213− }, [bootstrapComplete, navigate, routeThreadExists, threadId]);
241+ finalizePromotedDraftThreadByRef(threadRef);
242+ }, [draftThread?.promotedTo, serverThreadStarted, threadRef]);
214243
215− if (!bootstrapComplete || !routeThreadExists) {
244+ if (!threadRef || !bootstrapComplete || !routeThreadExists) {
216245 return null;
217246 }
218247
@@ -222,7 +251,11 @@ function ChatThreadRouteView() { · 3 unmodified lines
222251 return (
223252 <>
224253 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
225− <ChatView threadId={threadId} />
254+ <ChatView
255+ environmentId={threadRef.environmentId}
256+ threadId={threadRef.threadId}
257+ routeKind="server"
258+ />
226259 </SidebarInset>
227260 <DiffPanelInlineSidebar
228261 diffOpen={diffOpen}
@@ -237,7 +270,11 @@ function ChatThreadRouteView() { · 8 unmodified lines
237270 return (
238271 <>
239272 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
240− <ChatView threadId={threadId} />
273+ <ChatView
274+ environmentId={threadRef.environmentId}
275+ threadId={threadRef.threadId}
276+ routeKind="server"
277+ />
241278 </SidebarInset>
242279 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
243280 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}
@@ -246,7 +283,7 @@ function ChatThreadRouteView() { · 2 unmodified lines
246283 );
247284 }
248285
249−export const Route = createFileRoute("/_chat/$threadId")({
286+export const Route = createFileRoute("/_chat/$environmentId/$threadId")({
250287 validateSearch: (search) => parseDiffRouteSearch(search),
251288 search: {
252289 middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])],
apps/web/src/routes/_chat.draft.$draftId.tsx+86−0
@@ -0,0 +1,86 @@
1+import { createFileRoute, useNavigate } from "@tanstack/react-router";
2+import { useEffect, useMemo } from "react";
3+import ChatView from "../components/ChatView";
4+import { threadHasStarted } from "../components/ChatView.logic";
5+import { useComposerDraftStore, DraftId } from "../composerDraftStore";
6+import { SidebarInset } from "../components/ui/sidebar";
7+import { createThreadSelectorAcrossEnvironments } from "../storeSelectors";
8+import { useStore } from "../store";
9+import { buildThreadRouteParams } from "../threadRoutes";
10+
11+function DraftChatThreadRouteView() {
12+ const navigate = useNavigate();
13+ const { draftId: rawDraftId } = Route.useParams();
14+ const draftId = DraftId.makeUnsafe(rawDraftId);
15+ const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId));
16+ const serverThread = useStore(
17+ useMemo(
18+ () => createThreadSelectorAcrossEnvironments(draftSession?.threadId ?? null),
19+ [draftSession?.threadId],
20+ ),
21+ );
22+ const serverThreadStarted = threadHasStarted(serverThread);
23+ const canonicalThreadRef = useMemo(
24+ () =>
25+ draftSession?.promotedTo
26+ ? serverThreadStarted
27+ ? draftSession.promotedTo
28+ : null
29+ : serverThread
30+ ? {
31+ environmentId: serverThread.environmentId,
32+ threadId: serverThread.id,
33+ }
34+ : null,
35+ [draftSession?.promotedTo, serverThread, serverThreadStarted],
36+ );
37+
38+ useEffect(() => {
39+ if (!canonicalThreadRef) {
40+ return;
41+ }
42+ void navigate({
43+ to: "/$environmentId/$threadId",
44+ params: buildThreadRouteParams(canonicalThreadRef),
45+ replace: true,
46+ });
47+ }, [canonicalThreadRef, navigate]);
48+
49+ useEffect(() => {
50+ if (draftSession || canonicalThreadRef) {
51+ return;
52+ }
53+ void navigate({ to: "/", replace: true });
54+ }, [canonicalThreadRef, draftSession, navigate]);
55+
56+ if (canonicalThreadRef) {
57+ return (
58+ <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
59+ <ChatView
60+ environmentId={canonicalThreadRef.environmentId}
61+ threadId={canonicalThreadRef.threadId}
62+ routeKind="server"
63+ />
64+ </SidebarInset>
65+ );
66+ }
67+
68+ if (!draftSession) {
69+ return null;
70+ }
71+
72+ return (
73+ <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
74+ <ChatView
75+ draftId={draftId}
76+ environmentId={draftSession.environmentId}
77+ threadId={draftSession.threadId}
78+ routeKind="draft"
79+ />
80+ </SidebarInset>
81+ );
82+}
83+
84+export const Route = createFileRoute("/_chat/draft/$draftId")({
85+ component: DraftChatThreadRouteView,
86+});
apps/web/src/routes/_chat.tsx+17−11
@@ -1,3 +1,4 @@
1+import { scopeProjectRef } from "@t3tools/client-runtime";
12 import { Outlet, createFileRoute } from "@tanstack/react-router";
23 import { useEffect } from "react";
34
@@ -12,13 +13,13 @@ import { useServerKeybindings } from "~/rpc/serverState"; · 8 unmodified lines
1213
1314 function ChatRouteGlobalShortcuts() {
1415 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
15− const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size);
16− const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } =
16+ const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size);
17+ const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
1718 useHandleNewThread();
1819 const keybindings = useServerKeybindings();
1920 const terminalOpen = useTerminalStateStore((state) =>
20− routeThreadId
21− ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen
21+ routeThreadRef
22+ ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen
2223 : false,
2324 );
2425 const appSettings = useSettings();
@@ -27,14 +28,18 @@ function ChatRouteGlobalShortcuts() { · 2 unmodified lines
2728 const onWindowKeyDown = (event: KeyboardEvent) => {
2829 if (event.defaultPrevented) return;
2930
30− if (event.key === "Escape" && selectedThreadIdsSize > 0) {
31+ if (event.key === "Escape" && selectedThreadKeysSize > 0) {
3132 event.preventDefault();
3233 clearSelection();
3334 return;
3435 }
3536
36− const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId;
37− if (!projectId) return;
37+ const projectRef = activeThread
38+ ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
39+ : activeDraftThread && routeThreadRef
40+ ? scopeProjectRef(routeThreadRef.environmentId, activeDraftThread.projectId)
41+ : defaultProjectRef;
42+ if (!projectRef) return;
3843
3944 const command = resolveShortcutCommand(event, keybindings, {
4045 context: {
@@ -46,7 +51,7 @@ function ChatRouteGlobalShortcuts() { · 5 unmodified lines
4651 if (command === "chat.newLocal") {
4752 event.preventDefault();
4853 event.stopPropagation();
49− void handleNewThread(projectId, {
54+ void handleNewThread(projectRef, {
5055 envMode: resolveSidebarNewThreadEnvMode({
5156 defaultEnvMode: appSettings.defaultThreadEnvMode,
5257 }),
@@ -57,7 +62,7 @@ function ChatRouteGlobalShortcuts() { · 4 unmodified lines
5762 if (command === "chat.new") {
5863 event.preventDefault();
5964 event.stopPropagation();
60− void handleNewThread(projectId, {
65+ void handleNewThread(projectRef, {
6166 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
6267 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
6368 envMode:
@@ -77,8 +82,9 @@ function ChatRouteGlobalShortcuts() { · 13 unmodified lines
7782 clearSelection,
7883 handleNewThread,
7984 keybindings,
80− defaultProjectId,
81− selectedThreadIdsSize,
85+ defaultProjectRef,
86+ routeThreadRef,
87+ selectedThreadKeysSize,
8288 terminalOpen,
8389 appSettings.defaultThreadEnvMode,
8490 ]);
apps/web/src/rpc/serverState.test.ts+18−0
@@ -1,5 +1,6 @@
11 import {
22 DEFAULT_SERVER_SETTINGS,
3+ EnvironmentId,
34 ProjectId,
45 ThreadId,
56 type ServerConfig,
@@ -50,7 +51,21 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [ · 44 unmodified lines
5051 },
5152 ];
5253
54+const baseEnvironment = {
55+ environmentId: EnvironmentId.makeUnsafe("environment-local"),
56+ label: "Local environment",
57+ platform: {
58+ os: "darwin" as const,
59+ arch: "arm64" as const,
60+ },
61+ serverVersion: "0.0.0-test",
62+ capabilities: {
63+ repositoryIdentity: true,
64+ },
65+};
66+
5367 const baseServerConfig: ServerConfig = {
68+ environment: baseEnvironment,
5469 cwd: "/tmp/workspace",
5570 keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
5671 keybindings: [],
@@ -193,6 +208,7 @@ describe("serverState", () => { · 136 unmodified lines
193208 sequence: 1,
194209 type: "welcome",
195210 payload: {
211+ environment: baseEnvironment,
196212 cwd: "/tmp/workspace",
197213 projectName: "t3-code",
198214 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
@@ -201,6 +217,7 @@ describe("serverState", () => { · 2 unmodified lines
201217 });
202218
203219 expect(listener).toHaveBeenCalledWith({
220+ environment: baseEnvironment,
204221 cwd: "/tmp/workspace",
205222 projectName: "t3-code",
206223 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
@@ -210,6 +227,7 @@ describe("serverState", () => { · 3 unmodified lines
210227 const lateListener = vi.fn();
211228 const unsubscribeLate = onWelcome(lateListener);
212229 expect(lateListener).toHaveBeenCalledWith({
230+ environment: baseEnvironment,
213231 cwd: "/tmp/workspace",
214232 projectName: "t3-code",
215233 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
apps/web/src/store.test.ts+166−105
@@ -1,6 +1,7 @@
11 import {
22 CheckpointRef,
33 DEFAULT_MODEL_BY_PROVIDER,
4+ EnvironmentId,
45 EventId,
56 MessageId,
67 ProjectId,
@@ -14,16 +15,49 @@ import { describe, expect, it } from "vitest"; · 7 unmodified lines
1415 import {
1516 applyOrchestrationEvent,
1617 applyOrchestrationEvents,
17− selectProjects,
18− selectThreads,
18+ selectEnvironmentState,
19+ selectProjectsAcrossEnvironments,
20+ selectThreadsAcrossEnvironments,
1921 syncServerReadModel,
2022 type AppState,
23+ type EnvironmentState,
2124 } from "./store";
2225 import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
2326
27+const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
28+
29+function withActiveEnvironmentState(
30+ environmentState: EnvironmentState,
31+ overrides: Partial<AppState & EnvironmentState> = {},
32+): AppState {
33+ const {
34+ activeEnvironmentId: overrideActiveEnvironmentId,
35+ environmentStateById: overrideEnvironmentStateById,
36+ ...environmentOverrides
37+ } = overrides;
38+ const activeEnvironmentId = overrideActiveEnvironmentId ?? localEnvironmentId;
39+ const mergedEnvironmentState = {
40+ ...environmentState,
41+ ...environmentOverrides,
42+ };
43+ const environmentStateById =
44+ overrideEnvironmentStateById ??
45+ (activeEnvironmentId
46+ ? {
47+ [activeEnvironmentId]: mergedEnvironmentState,
48+ }
49+ : {});
50+
51+ return {
52+ activeEnvironmentId,
53+ environmentStateById,
54+ };
55+}
56+
2457 function makeThread(overrides: Partial<Thread> = {}): Thread {
2558 return {
2659 id: ThreadId.makeUnsafe("thread-1"),
60+ environmentId: localEnvironmentId,
2761 codexThreadId: null,
2862 projectId: ProjectId.makeUnsafe("project-1"),
2963 title: "Thread",
@@ -52,6 +86,7 @@ function makeState(thread: Thread): AppState { · 22 unmodified lines
5286 const projectId = ProjectId.makeUnsafe("project-1");
5387 const project = {
5488 id: projectId,
89+ environmentId: localEnvironmentId,
5590 name: "Project",
5691 cwd: "/tmp/project",
5792 defaultModelSelection: {
@@ -62,10 +97,10 @@ function makeState(thread: Thread): AppState { · 4 unmodified lines
6297 updatedAt: "2026-02-13T00:00:00.000Z",
6398 scripts: [],
6499 };
65− const threadIdsByProjectId: AppState["threadIdsByProjectId"] = {
100+ const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = {
66101 [thread.projectId]: [thread.id],
67102 };
68− return {
103+ const environmentState = {
69104 projectIds: [projectId],
70105 projectById: {
71106 [projectId]: project,
@@ -75,6 +110,7 @@ function makeState(thread: Thread): AppState { · 3 unmodified lines
75110 threadShellById: {
76111 [thread.id]: {
77112 id: thread.id,
113+ environmentId: thread.environmentId,
78114 codexThreadId: thread.codexThreadId,
79115 projectId: thread.projectId,
80116 title: thread.title,
@@ -106,7 +142,7 @@ function makeState(thread: Thread): AppState { · 25 unmodified lines
106142 messageByThreadId: {
107143 [thread.id]: Object.fromEntries(
108144 thread.messages.map((message) => [message.id, message] as const),
109− ) as AppState["messageByThreadId"][ThreadId],
145+ ) as EnvironmentState["messageByThreadId"][ThreadId],
110146 },
111147 activityIdsByThreadId: {
112148 [thread.id]: thread.activities.map((activity) => activity.id),
@@ -114,7 +150,7 @@ function makeState(thread: Thread): AppState { · 1 unmodified line
114150 activityByThreadId: {
115151 [thread.id]: Object.fromEntries(
116152 thread.activities.map((activity) => [activity.id, activity] as const),
117− ) as AppState["activityByThreadId"][ThreadId],
153+ ) as EnvironmentState["activityByThreadId"][ThreadId],
118154 },
119155 proposedPlanIdsByThreadId: {
120156 [thread.id]: thread.proposedPlans.map((plan) => plan.id),
@@ -122,7 +158,7 @@ function makeState(thread: Thread): AppState { · 1 unmodified line
122158 proposedPlanByThreadId: {
123159 [thread.id]: Object.fromEntries(
124160 thread.proposedPlans.map((plan) => [plan.id, plan] as const),
125− ) as AppState["proposedPlanByThreadId"][ThreadId],
161+ ) as EnvironmentState["proposedPlanByThreadId"][ThreadId],
126162 },
127163 turnDiffIdsByThreadId: {
128164 [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId),
@@ -130,15 +166,16 @@ function makeState(thread: Thread): AppState { · 1 unmodified line
130166 turnDiffSummaryByThreadId: {
131167 [thread.id]: Object.fromEntries(
132168 thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const),
133− ) as AppState["turnDiffSummaryByThreadId"][ThreadId],
169+ ) as EnvironmentState["turnDiffSummaryByThreadId"][ThreadId],
134170 },
135171 sidebarThreadSummaryById: {},
136172 bootstrapComplete: true,
137173 };
174+ return withActiveEnvironmentState(environmentState);
138175 }
139176
140−function makeEmptyState(overrides: Partial<AppState> = {}): AppState {
141− return {
177+function makeEmptyState(overrides: Partial<AppState & EnvironmentState> = {}): AppState {
178+ const environmentState: EnvironmentState = {
142179 projectIds: [],
143180 projectById: {},
144181 threadIds: [],
@@ -156,16 +193,20 @@ function makeEmptyState(overrides: Partial<AppState> = {}): AppState { · 11 unmodified lines
156193 turnDiffSummaryByThreadId: {},
157194 sidebarThreadSummaryById: {},
158195 bootstrapComplete: true,
159− ...overrides,
160196 };
197+ return withActiveEnvironmentState(environmentState, overrides);
198+}
199+
200+function localEnvironmentStateOf(state: AppState): EnvironmentState {
201+ return selectEnvironmentState(state, localEnvironmentId);
161202 }
162203
163204 function projectsOf(state: AppState) {
164− return selectProjects(state);
205+ return selectProjectsAcrossEnvironments(state);
165206 }
166207
167208 function threadsOf(state: AppState) {
168− return selectThreads(state);
209+ return selectThreadsAcrossEnvironments(state);
169210 }
170211
171212 function makeEvent<T extends OrchestrationEvent["type"]>(
@@ -266,14 +307,20 @@ function makeReadModelProject( · 94 unmodified lines
266307
267308 describe("store read model sync", () => {
268309 it("marks bootstrap complete after snapshot sync", () => {
269− const initialState: AppState = {
270− ...makeState(makeThread()),
271− bootstrapComplete: false,
272− };
310+ const initialState = withActiveEnvironmentState(
311+ localEnvironmentStateOf(makeState(makeThread())),
312+ {
313+ bootstrapComplete: false,
314+ },
315+ );
273316
274− const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({})));
317+ const next = syncServerReadModel(
318+ initialState,
319+ makeReadModel(makeReadModelThread({})),
320+ localEnvironmentId,
321+ );
275322
276− expect(next.bootstrapComplete).toBe(true);
323+ expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true);
277324 });
278325
279326 it("preserves claude model slugs without an active session", () => {
@@ -287,7 +334,7 @@ describe("store read model sync", () => { · 7 unmodified lines
287334 }),
288335 );
289336
290− const next = syncServerReadModel(initialState, readModel);
337+ const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
291338
292339 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6");
293340 });
@@ -312,7 +359,7 @@ describe("store read model sync", () => { · 18 unmodified lines
312359 }),
313360 );
314361
315− const next = syncServerReadModel(initialState, readModel);
362+ const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
316363
317364 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6");
318365 });
@@ -325,7 +372,7 @@ describe("store read model sync", () => { · 6 unmodified lines
325372 }),
326373 );
327374
328− const next = syncServerReadModel(initialState, readModel);
375+ const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
329376
330377 expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z");
331378 expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z");
@@ -341,6 +388,7 @@ describe("store read model sync", () => { · 9 unmodified lines
341388 archivedAt,
342389 }),
343390 ),
391+ localEnvironmentId,
344392 );
345393
346394 expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt);
@@ -355,6 +403,7 @@ describe("store read model sync", () => { · 8 unmodified lines
355403 projectById: {
356404 [project2]: {
357405 id: project2,
406+ environmentId: localEnvironmentId,
358407 name: "Project 2",
359408 cwd: "/tmp/project-2",
360409 defaultModelSelection: {
@@ -367,6 +416,7 @@ describe("store read model sync", () => { · 6 unmodified lines
367416 },
368417 [project1]: {
369418 id: project1,
419+ environmentId: localEnvironmentId,
370420 name: "Project 1",
371421 cwd: "/tmp/project-1",
372422 defaultModelSelection: {
@@ -402,7 +452,7 @@ describe("store read model sync", () => { · 29 unmodified lines
402452 threads: [],
403453 };
404454
405− const next = syncServerReadModel(initialState, readModel);
455+ const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
406456
407457 expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]);
408458 });
@@ -410,10 +460,9 @@ describe("store read model sync", () => { · 1 unmodified line
410460
411461 describe("incremental orchestration updates", () => {
412462 it("does not mark bootstrap complete for incremental events", () => {
413− const state: AppState = {
414− ...makeState(makeThread()),
463+ const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), {
415464 bootstrapComplete: false,
416− };
465+ });
417466
418467 const next = applyOrchestrationEvent(
419468 state,
@@ -422,30 +471,10 @@ describe("incremental orchestration updates", () => { · 2 unmodified lines
422471 title: "Updated title",
423472 updatedAt: "2026-02-27T00:00:01.000Z",
424473 }),
474+ localEnvironmentId,
425475 );
426476
427− expect(next.bootstrapComplete).toBe(false);
428− });
429−
430− it("updates the existing project title when project.meta-updated arrives", () => {
431− const projectId = ProjectId.makeUnsafe("project-1");
432− const state = makeState(
433− makeThread({
434− projectId,
435− }),
436− );
437−
438− const next = applyOrchestrationEvent(
439− state,
440− makeEvent("project.meta-updated", {
441− projectId,
442− title: "Renamed Project",
443− updatedAt: "2026-02-27T00:00:01.000Z",
444− }),
445− );
446−
447− expect(next.projectById[projectId]?.name).toBe("Renamed Project");
448− expect(next.projectById[projectId]?.updatedAt).toBe("2026-02-27T00:00:01.000Z");
477+ expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(false);
449478 });
450479
451480 it("preserves state identity for no-op project and thread deletes", () => {
@@ -458,6 +487,7 @@ describe("incremental orchestration updates", () => { · 6 unmodified lines
458487 projectId: ProjectId.makeUnsafe("project-missing"),
459488 deletedAt: "2026-02-27T00:00:01.000Z",
460489 }),
490+ localEnvironmentId,
461491 );
462492 const nextAfterThreadDelete = applyOrchestrationEvent(
463493 state,
@@ -465,6 +495,7 @@ describe("incremental orchestration updates", () => { · 1 unmodified line
465495 threadId: ThreadId.makeUnsafe("thread-missing"),
466496 deletedAt: "2026-02-27T00:00:01.000Z",
467497 }),
498+ localEnvironmentId,
468499 );
469500
470501 expect(nextAfterProjectDelete).toBe(state);
@@ -479,6 +510,7 @@ describe("incremental orchestration updates", () => { · 8 unmodified lines
479510 projectById: {
480511 [originalProjectId]: {
481512 id: originalProjectId,
513+ environmentId: localEnvironmentId,
482514 name: "Project",
483515 cwd: "/tmp/project",
484516 defaultModelSelection: {
@@ -506,15 +538,18 @@ describe("incremental orchestration updates", () => { · 21 unmodified lines
506538 createdAt: "2026-02-27T00:00:01.000Z",
507539 updatedAt: "2026-02-27T00:00:01.000Z",
508540 }),
541+ localEnvironmentId,
509542 );
510543
511544 expect(projectsOf(next)).toHaveLength(1);
512545 expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId);
513546 expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project");
514547 expect(projectsOf(next)[0]?.name).toBe("Project Recreated");
515− expect(next.projectIds).toEqual([recreatedProjectId]);
516− expect(next.projectById[originalProjectId]).toBeUndefined();
517− expect(next.projectById[recreatedProjectId]?.id).toBe(recreatedProjectId);
548+ expect(localEnvironmentStateOf(next).projectIds).toEqual([recreatedProjectId]);
549+ expect(localEnvironmentStateOf(next).projectById[originalProjectId]).toBeUndefined();
550+ expect(localEnvironmentStateOf(next).projectById[recreatedProjectId]?.id).toBe(
551+ recreatedProjectId,
552+ );
518553 });
519554
520555 it("removes stale project index entries when thread.created recreates a thread under a new project", () => {
@@ -525,12 +560,12 @@ describe("incremental orchestration updates", () => { · 4 unmodified lines
525560 id: threadId,
526561 projectId: originalProjectId,
527562 });
528− const state: AppState = {
529− ...makeState(thread),
563+ const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(thread)), {
530564 projectIds: [originalProjectId, recreatedProjectId],
531565 projectById: {
532566 [originalProjectId]: {
533567 id: originalProjectId,
568+ environmentId: localEnvironmentId,
534569 name: "Project 1",
535570 cwd: "/tmp/project-1",
536571 defaultModelSelection: {
@@ -543,6 +578,7 @@ describe("incremental orchestration updates", () => { · 6 unmodified lines
543578 },
544579 [recreatedProjectId]: {
545580 id: recreatedProjectId,
581+ environmentId: localEnvironmentId,
546582 name: "Project 2",
547583 cwd: "/tmp/project-2",
548584 defaultModelSelection: {
@@ -554,7 +590,7 @@ describe("incremental orchestration updates", () => { · 5 unmodified lines
554590 scripts: [],
555591 },
556592 },
557− };
593+ });
558594
559595 const next = applyOrchestrationEvent(
560596 state,
@@ -573,12 +609,15 @@ describe("incremental orchestration updates", () => { · 12 unmodified lines
573609 createdAt: "2026-02-27T00:00:01.000Z",
574610 updatedAt: "2026-02-27T00:00:01.000Z",
575611 }),
612+ localEnvironmentId,
576613 );
577614
578615 expect(threadsOf(next)).toHaveLength(1);
579616 expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId);
580− expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined();
581− expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]);
617+ expect(localEnvironmentStateOf(next).threadIdsByProjectId[originalProjectId]).toBeUndefined();
618+ expect(localEnvironmentStateOf(next).threadIdsByProjectId[recreatedProjectId]).toEqual([
619+ threadId,
620+ ]);
582621 });
583622
584623 it("updates only the affected thread for message events", () => {
@@ -597,13 +636,15 @@ describe("incremental orchestration updates", () => { · 12 unmodified lines
597636 ],
598637 });
599638 const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") });
600− const state: AppState = {
601− ...makeState(thread1),
639+ const baseState = makeState(thread1);
640+ const baseEnvironmentState = localEnvironmentStateOf(baseState);
641+ const state = withActiveEnvironmentState(baseEnvironmentState, {
602642 threadIds: [thread1.id, thread2.id],
603643 threadShellById: {
604− ...makeState(thread1).threadShellById,
644+ ...baseEnvironmentState.threadShellById,
605645 [thread2.id]: {
606646 id: thread2.id,
647+ environmentId: thread2.environmentId,
607648 codexThreadId: thread2.codexThreadId,
608649 projectId: thread2.projectId,
609650 title: thread2.title,
@@ -619,54 +660,54 @@ describe("incremental orchestration updates", () => { · 9 unmodified lines
619660 },
620661 },
621662 threadSessionById: {
622− ...makeState(thread1).threadSessionById,
663+ ...baseEnvironmentState.threadSessionById,
623664 [thread2.id]: thread2.session,
624665 },
625666 threadTurnStateById: {
626− ...makeState(thread1).threadTurnStateById,
667+ ...baseEnvironmentState.threadTurnStateById,
627668 [thread2.id]: {
628669 latestTurn: thread2.latestTurn,
629670 },
630671 },
631672 messageIdsByThreadId: {
632− ...makeState(thread1).messageIdsByThreadId,
673+ ...baseEnvironmentState.messageIdsByThreadId,
633674 [thread2.id]: [],
634675 },
635676 messageByThreadId: {
636− ...makeState(thread1).messageByThreadId,
677+ ...baseEnvironmentState.messageByThreadId,
637678 [thread2.id]: {},
638679 },
639680 activityIdsByThreadId: {
640− ...makeState(thread1).activityIdsByThreadId,
681+ ...baseEnvironmentState.activityIdsByThreadId,
641682 [thread2.id]: [],
642683 },
643684 activityByThreadId: {
644− ...makeState(thread1).activityByThreadId,
685+ ...baseEnvironmentState.activityByThreadId,
645686 [thread2.id]: {},
646687 },
647688 proposedPlanIdsByThreadId: {
648− ...makeState(thread1).proposedPlanIdsByThreadId,
689+ ...baseEnvironmentState.proposedPlanIdsByThreadId,
649690 [thread2.id]: [],
650691 },
651692 proposedPlanByThreadId: {
652− ...makeState(thread1).proposedPlanByThreadId,
693+ ...baseEnvironmentState.proposedPlanByThreadId,
653694 [thread2.id]: {},
654695 },
655696 turnDiffIdsByThreadId: {
656− ...makeState(thread1).turnDiffIdsByThreadId,
697+ ...baseEnvironmentState.turnDiffIdsByThreadId,
657698 [thread2.id]: [],
658699 },
659700 turnDiffSummaryByThreadId: {
660− ...makeState(thread1).turnDiffSummaryByThreadId,
701+ ...baseEnvironmentState.turnDiffSummaryByThreadId,
661702 [thread2.id]: {},
662703 },
663704 sidebarThreadSummaryById: {
664− ...makeState(thread1).sidebarThreadSummaryById,
705+ ...baseEnvironmentState.sidebarThreadSummaryById,
665706 },
666707 threadIdsByProjectId: {
667708 [thread1.projectId]: [thread1.id, thread2.id],
668709 },
669− };
710+ });
670711
671712 const next = applyOrchestrationEvent(
672713 state,
@@ -680,14 +721,25 @@ describe("incremental orchestration updates", () => { · 7 unmodified lines
680721 createdAt: "2026-02-27T00:00:01.000Z",
681722 updatedAt: "2026-02-27T00:00:01.000Z",
682723 }),
724+ localEnvironmentId,
683725 );
684726
685727 expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world");
686728 expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running");
687− expect(next.threadShellById[thread2.id]).toBe(state.threadShellById[thread2.id]);
688− expect(next.threadSessionById[thread2.id]).toBe(state.threadSessionById[thread2.id]);
689− expect(next.messageIdsByThreadId[thread2.id]).toBe(state.messageIdsByThreadId[thread2.id]);
690− expect(next.messageByThreadId[thread2.id]).toBe(state.messageByThreadId[thread2.id]);
729+ const nextEnvironmentState = next.environmentStateById[localEnvironmentId];
730+ const previousEnvironmentState = state.environmentStateById[localEnvironmentId];
731+ expect(nextEnvironmentState?.threadShellById[thread2.id]).toBe(
732+ previousEnvironmentState?.threadShellById[thread2.id],
733+ );
734+ expect(nextEnvironmentState?.threadSessionById[thread2.id]).toBe(
735+ previousEnvironmentState?.threadSessionById[thread2.id],
736+ );
737+ expect(nextEnvironmentState?.messageIdsByThreadId[thread2.id]).toBe(
738+ previousEnvironmentState?.messageIdsByThreadId[thread2.id],
739+ );
740+ expect(nextEnvironmentState?.messageByThreadId[thread2.id]).toBe(
741+ previousEnvironmentState?.messageByThreadId[thread2.id],
742+ );
691743 });
692744
693745 it("applies replay batches in sequence and updates session state", () => {
@@ -703,38 +755,42 @@ describe("incremental orchestration updates", () => { · 9 unmodified lines
703755 });
704756 const state = makeState(thread);
705757
706− const next = applyOrchestrationEvents(state, [
707− makeEvent(
708− "thread.session-set",
709− {
710− threadId: thread.id,
711− session: {
758+ const next = applyOrchestrationEvents(
759+ state,
760+ [
761+ makeEvent(
762+ "thread.session-set",
763+ {
712764 threadId: thread.id,
713− status: "running",
714− providerName: "codex",
715− runtimeMode: "full-access",
716− activeTurnId: TurnId.makeUnsafe("turn-1"),
717− lastError: null,
718− updatedAt: "2026-02-27T00:00:02.000Z",
765+ session: {
766+ threadId: thread.id,
767+ status: "running",
768+ providerName: "codex",
769+ runtimeMode: "full-access",
770+ activeTurnId: TurnId.makeUnsafe("turn-1"),
771+ lastError: null,
772+ updatedAt: "2026-02-27T00:00:02.000Z",
773+ },
719774 },
720− },
721− { sequence: 2 },
722− ),
723− makeEvent(
724− "thread.message-sent",
725− {
726− threadId: thread.id,
727− messageId: MessageId.makeUnsafe("assistant-1"),
728− role: "assistant",
729− text: "done",
730− turnId: TurnId.makeUnsafe("turn-1"),
731− streaming: false,
732− createdAt: "2026-02-27T00:00:03.000Z",
733− updatedAt: "2026-02-27T00:00:03.000Z",
734− },
735− { sequence: 3 },
736− ),
737− ]);
775+ { sequence: 2 },
776+ ),
777+ makeEvent(
778+ "thread.message-sent",
779+ {
780+ threadId: thread.id,
781+ messageId: MessageId.makeUnsafe("assistant-1"),
782+ role: "assistant",
783+ text: "done",
784+ turnId: TurnId.makeUnsafe("turn-1"),
785+ streaming: false,
786+ createdAt: "2026-02-27T00:00:03.000Z",
787+ updatedAt: "2026-02-27T00:00:03.000Z",
788+ },
789+ { sequence: 3 },
790+ ),
791+ ],
792+ localEnvironmentId,
793+ );
738794
739795 expect(threadsOf(next)[0]?.session?.status).toBe("running");
740796 expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed");
@@ -767,6 +823,7 @@ describe("incremental orchestration updates", () => { · 26 unmodified lines
767823 assistantMessageId: MessageId.makeUnsafe("assistant-1"),
768824 completedAt: "2026-02-27T00:00:04.000Z",
769825 }),
826+ localEnvironmentId,
770827 );
771828
772829 expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1);
@@ -811,6 +868,7 @@ describe("incremental orchestration updates", () => { · 38 unmodified lines
811868 createdAt: "2026-02-27T00:00:03.000Z",
812869 updatedAt: "2026-02-27T00:00:03.000Z",
813870 }),
871+ localEnvironmentId,
814872 );
815873
816874 expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe(
@@ -920,6 +978,7 @@ describe("incremental orchestration updates", () => { · 103 unmodified lines
920978 threadId: ThreadId.makeUnsafe("thread-1"),
921979 turnCount: 1,
922980 }),
981+ localEnvironmentId,
923982 );
924983
925984 expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([
@@ -978,6 +1037,7 @@ describe("incremental orchestration updates", () => { · 52 unmodified lines
9781037 threadId: thread.id,
9791038 turnCount: 1,
9801039 }),
1040+ localEnvironmentId,
9811041 );
9821042
9831043 expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined();
@@ -996,6 +1056,7 @@ describe("incremental orchestration updates", () => { · 12 unmodified lines
9961056 updatedAt: "2026-02-27T00:00:04.000Z",
9971057 },
9981058 }),
1059+ localEnvironmentId,
9991060 );
10001061
10011062 expect(threadsOf(next)[0]?.latestTurn).toMatchObject({
apps/web/src/store.ts+336−92
@@ -1,4 +1,5 @@
11 import {
2+ type EnvironmentId,
23 type MessageId,
34 type OrchestrationCheckpointSummary,
45 type OrchestrationEvent,
@@ -11,6 +12,8 @@ import { · 6 unmodified lines
1112 type OrchestrationThreadActivity,
1213 type ProjectId,
1314 type ProviderKind,
15+ type ScopedProjectRef,
16+ type ScopedThreadRef,
1417 ThreadId,
1518 type TurnId,
1619 } from "@t3tools/contracts";
@@ -35,7 +38,7 @@ import { · 18 unmodified lines
3538 } from "./types";
3639 import { sanitizeThreadErrorMessage } from "./rpc/transportError";
3740
38−export interface AppState {
41+export interface EnvironmentState {
3942 projectIds: ProjectId[];
4043 projectById: Record<ProjectId, Project>;
4144 threadIds: ThreadId[];
@@ -55,7 +58,12 @@ export interface AppState { · 13 unmodified lines
5558 bootstrapComplete: boolean;
5659 }
5760
58−const initialState: AppState = {
61+export interface AppState {
62+ activeEnvironmentId: EnvironmentId | null;
63+ environmentStateById: Record<string, EnvironmentState>;
64+}
65+
66+const initialEnvironmentState: EnvironmentState = {
5967 projectIds: [],
6068 projectById: {},
6169 threadIds: [],
@@ -75,6 +83,11 @@ const initialState: AppState = { · 13 unmodified lines
7583 bootstrapComplete: false,
7684 };
7785
86+const initialState: AppState = {
87+ activeEnvironmentId: null,
88+ environmentStateById: {},
89+};
90+
7891 const MAX_THREAD_MESSAGES = 2_000;
7992 const MAX_THREAD_CHECKPOINTS = 500;
8093 const MAX_THREAD_PROPOSED_PLANS = 200;
@@ -169,11 +182,16 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif · 88 unmodified lines
169182 };
170183 }
171184
172−function mapProject(project: OrchestrationReadModel["projects"][number]): Project {
185+function mapProject(
186+ project: OrchestrationReadModel["projects"][number],
187+ environmentId: EnvironmentId,
188+): Project {
173189 return {
174190 id: project.id,
191+ environmentId,
175192 name: project.title,
176193 cwd: project.workspaceRoot,
194+ repositoryIdentity: project.repositoryIdentity ?? null,
177195 defaultModelSelection: project.defaultModelSelection
178196 ? normalizeModelSelection(project.defaultModelSelection)
179197 : null,
@@ -183,9 +201,10 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec · 3 unmodified lines
183201 };
184202 }
185203
186−function mapThread(thread: OrchestrationThread): Thread {
204+function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread {
187205 return {
188206 id: thread.id,
207+ environmentId,
189208 codexThreadId: null,
190209 projectId: thread.projectId,
191210 title: thread.title,
@@ -211,6 +230,7 @@ function mapThread(thread: OrchestrationThread): Thread { · 19 unmodified lines
211230 function toThreadShell(thread: Thread): ThreadShell {
212231 return {
213232 id: thread.id,
233+ environmentId: thread.environmentId,
214234 codexThreadId: thread.codexThreadId,
215235 projectId: thread.projectId,
216236 title: thread.title,
@@ -251,6 +271,7 @@ function getLatestUserMessageAt(messages: ReadonlyArray<ChatMessage>): string | · 34 unmodified lines
251271 function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary {
252272 return {
253273 id: thread.id,
274+ environmentId: thread.environmentId,
254275 projectId: thread.projectId,
255276 title: thread.title,
256277 interactionMode: thread.interactionMode,
@@ -298,6 +319,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b · 41 unmodified lines
298319 return (
299320 left !== undefined &&
300321 left.id === right.id &&
322+ left.environmentId === right.environmentId &&
301323 left.codexThreadId === right.codexThreadId &&
302324 left.projectId === right.projectId &&
303325 left.title === right.title &&
@@ -377,7 +399,7 @@ function buildTurnDiffSlice(thread: Thread): { · 73 unmodified lines
377399 };
378400 }
379401
380−function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] {
402+function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] {
381403 const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS;
382404 const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP;
383405 if (ids.length === 0) {
@@ -390,7 +412,7 @@ function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[ · 6 unmodified lines
390412 }
391413
392414 function selectThreadActivities(
393− state: AppState,
415+ state: EnvironmentState,
394416 threadId: ThreadId,
395417 ): OrchestrationThreadActivity[] {
396418 const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS;
@@ -404,7 +426,7 @@ function selectThreadActivities( · 7 unmodified lines
404426 });
405427 }
406428
407−function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] {
429+function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] {
408430 const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS;
409431 const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP;
410432 if (ids.length === 0) {
@@ -416,7 +438,10 @@ function selectThreadProposedPlans(state: AppState, threadId: ThreadId): Propose · 5 unmodified lines
416438 });
417439 }
418440
419−function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] {
441+function selectThreadTurnDiffSummaries(
442+ state: EnvironmentState,
443+ threadId: ThreadId,
444+): TurnDiffSummary[] {
420445 const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS;
421446 const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP;
422447 if (ids.length === 0) {
@@ -428,7 +453,7 @@ function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): Tur · 5 unmodified lines
428453 });
429454 }
430455
431−function getThread(state: AppState, threadId: ThreadId): Thread | undefined {
456+function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined {
432457 const shell = state.threadShellById[threadId];
433458 if (!shell) {
434459 return undefined;
@@ -446,21 +471,25 @@ function getThread(state: AppState, threadId: ThreadId): Thread | undefined { · 11 unmodified lines
446471 };
447472 }
448473
449−function getProjects(state: AppState): Project[] {
474+function getProjects(state: EnvironmentState): Project[] {
450475 return state.projectIds.flatMap((projectId) => {
451476 const project = state.projectById[projectId];
452477 return project ? [project] : [];
453478 });
454479 }
455480
456−function getThreads(state: AppState): Thread[] {
481+function getThreads(state: EnvironmentState): Thread[] {
457482 return state.threadIds.flatMap((threadId) => {
458483 const thread = getThread(state, threadId);
459484 return thread ? [thread] : [];
460485 });
461486 }
462487
463−function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState {
488+function writeThreadState(
489+ state: EnvironmentState,
490+ nextThread: Thread,
491+ previousThread?: Thread,
492+): EnvironmentState {
464493 const nextShell = toThreadShell(nextThread);
465494 const nextTurnState = toThreadTurnState(nextThread);
466495 const previousShell = state.threadShellById[nextThread.id];
@@ -613,7 +642,7 @@ function writeThreadState(state: AppState, nextThread: Thread, previousThread?: · 146 unmodified lines
613642 return nextState;
614643 }
615644
616−function removeThreadState(state: AppState, threadId: ThreadId): AppState {
645+function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState {
617646 const shell = state.threadShellById[threadId];
618647 if (!shell) {
619648 return state;
@@ -887,10 +916,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string { · 267 unmodified lines
887916 }
888917
889918 function updateThreadState(
890− state: AppState,
919+ state: EnvironmentState,
891920 threadId: ThreadId,
892921 updater: (thread: Thread) => Thread,
893−): AppState {
922+): EnvironmentState {
894923 const currentThread = getThread(state, threadId);
895924 if (!currentThread) {
896925 return state;
@@ -904,7 +933,7 @@ function updateThreadState( · 7 unmodified lines
904933
905934 function buildProjectState(
906935 projects: ReadonlyArray<Project>,
907−): Pick<AppState, "projectIds" | "projectById"> {
936+): Pick<EnvironmentState, "projectIds" | "projectById"> {
908937 return {
909938 projectIds: projects.map((project) => project.id),
910939 projectById: Object.fromEntries(
@@ -916,7 +945,7 @@ function buildProjectState( · 5 unmodified lines
916945 function buildThreadState(
917946 threads: ReadonlyArray<Thread>,
918947 ): Pick<
919− AppState,
948+ EnvironmentState,
920949 | "threadIds"
921950 | "threadIdsByProjectId"
922951 | "threadShellById"
@@ -989,11 +1018,48 @@ function buildThreadState( · 66 unmodified lines
9891018 };
9901019 }
9911020
992−export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState {
1021+function getStoredEnvironmentState(
1022+ state: AppState,
1023+ environmentId: EnvironmentId,
1024+): EnvironmentState {
1025+ return state.environmentStateById[environmentId] ?? initialEnvironmentState;
1026+}
1027+
1028+function commitEnvironmentState(
1029+ state: AppState,
1030+ environmentId: EnvironmentId,
1031+ nextEnvironmentState: EnvironmentState,
1032+): AppState {
1033+ const currentEnvironmentState = state.environmentStateById[environmentId];
1034+ const environmentStateById =
1035+ currentEnvironmentState === nextEnvironmentState
1036+ ? state.environmentStateById
1037+ : {
1038+ ...state.environmentStateById,
1039+ [environmentId]: nextEnvironmentState,
1040+ };
1041+
1042+ if (environmentStateById === state.environmentStateById) {
1043+ return state;
1044+ }
1045+
1046+ return {
1047+ ...state,
1048+ environmentStateById,
1049+ };
1050+}
1051+
1052+function syncEnvironmentReadModel(
1053+ state: EnvironmentState,
1054+ readModel: OrchestrationReadModel,
1055+ environmentId: EnvironmentId,
1056+): EnvironmentState {
9931057 const projects = readModel.projects
9941058 .filter((project) => project.deletedAt === null)
995− .map(mapProject);
996− const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread);
1059+ .map((project) => mapProject(project, environmentId));
1060+ const threads = readModel.threads
1061+ .filter((thread) => thread.deletedAt === null)
1062+ .map((thread) => mapThread(thread, environmentId));
9971063 return {
9981064 ...state,
9991065 ...buildProjectState(projects),
@@ -1002,19 +1068,43 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea · 2 unmodified lines
10021068 };
10031069 }
10041070
1005−export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState {
1071+export function syncServerReadModel(
1072+ state: AppState,
1073+ readModel: OrchestrationReadModel,
1074+ environmentId: EnvironmentId,
1075+): AppState {
1076+ return commitEnvironmentState(
1077+ state,
1078+ environmentId,
1079+ syncEnvironmentReadModel(
1080+ getStoredEnvironmentState(state, environmentId),
1081+ readModel,
1082+ environmentId,
1083+ ),
1084+ );
1085+}
1086+
1087+function applyEnvironmentOrchestrationEvent(
1088+ state: EnvironmentState,
1089+ event: OrchestrationEvent,
1090+ environmentId: EnvironmentId,
1091+): EnvironmentState {
10061092 switch (event.type) {
10071093 case "project.created": {
1008− const nextProject = mapProject({
1009− id: event.payload.projectId,
1010− title: event.payload.title,
1011− workspaceRoot: event.payload.workspaceRoot,
1012− defaultModelSelection: event.payload.defaultModelSelection,
1013− scripts: event.payload.scripts,
1014− createdAt: event.payload.createdAt,
1015− updatedAt: event.payload.updatedAt,
1016− deletedAt: null,
1017− });
1094+ const nextProject = mapProject(
1095+ {
1096+ id: event.payload.projectId,
1097+ title: event.payload.title,
1098+ workspaceRoot: event.payload.workspaceRoot,
1099+ repositoryIdentity: event.payload.repositoryIdentity ?? null,
1100+ defaultModelSelection: event.payload.defaultModelSelection,
1101+ scripts: event.payload.scripts,
1102+ createdAt: event.payload.createdAt,
1103+ updatedAt: event.payload.updatedAt,
1104+ deletedAt: null,
1105+ },
1106+ environmentId,
1107+ );
10181108 const existingProjectId =
10191109 state.projectIds.find(
10201110 (projectId) =>
@@ -1060,6 +1150,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve · 39 unmodified lines
10601150 ...project,
10611151 ...(event.payload.title !== undefined ? { name: event.payload.title } : {}),
10621152 ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}),
1153+ ...(event.payload.repositoryIdentity !== undefined
1154+ ? { repositoryIdentity: event.payload.repositoryIdentity ?? null }
1155+ : {}),
10631156 ...(event.payload.defaultModelSelection !== undefined
10641157 ? {
10651158 defaultModelSelection: event.payload.defaultModelSelection
@@ -1095,26 +1188,29 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve · 29 unmodified lines
10951188
10961189 case "thread.created": {
10971190 const previousThread = getThread(state, event.payload.threadId);
1098− const nextThread = mapThread({
1099− id: event.payload.threadId,
1100− projectId: event.payload.projectId,
1101− title: event.payload.title,
1102− modelSelection: event.payload.modelSelection,
1103− runtimeMode: event.payload.runtimeMode,
1104− interactionMode: event.payload.interactionMode,
1105− branch: event.payload.branch,
1106− worktreePath: event.payload.worktreePath,
1107− latestTurn: null,
1108− createdAt: event.payload.createdAt,
1109− updatedAt: event.payload.updatedAt,
1110− archivedAt: null,
1111− deletedAt: null,
1112− messages: [],
1113− proposedPlans: [],
1114− activities: [],
1115− checkpoints: [],
1116− session: null,
1117− });
1191+ const nextThread = mapThread(
1192+ {
1193+ id: event.payload.threadId,
1194+ projectId: event.payload.projectId,
1195+ title: event.payload.title,
1196+ modelSelection: event.payload.modelSelection,
1197+ runtimeMode: event.payload.runtimeMode,
1198+ interactionMode: event.payload.interactionMode,
1199+ branch: event.payload.branch,
1200+ worktreePath: event.payload.worktreePath,
1201+ latestTurn: null,
1202+ createdAt: event.payload.createdAt,
1203+ updatedAt: event.payload.updatedAt,
1204+ archivedAt: null,
1205+ deletedAt: null,
1206+ messages: [],
1207+ proposedPlans: [],
1208+ activities: [],
1209+ checkpoints: [],
1210+ session: null,
1211+ },
1212+ environmentId,
1213+ );
11181214 return writeThreadState(state, nextThread, previousThread);
11191215 }
11201216
@@ -1481,70 +1577,218 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve · 360 unmodified lines
14811577 export function applyOrchestrationEvents(
14821578 state: AppState,
14831579 events: ReadonlyArray<OrchestrationEvent>,
1580+ environmentId: EnvironmentId,
14841581 ): AppState {
14851582 if (events.length === 0) {
14861583 return state;
14871584 }
1488− return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state);
1489−}
1490−
1491−export const selectProjects = (state: AppState): Project[] => getProjects(state);
1492−export const selectThreads = (state: AppState): Thread[] => getThreads(state);
1493−export const selectProjectById =
1494− (projectId: Project["id"] | null | undefined) =>
1495− (state: AppState): Project | undefined =>
1496− projectId ? state.projectById[projectId] : undefined;
1497−export const selectThreadById =
1498− (threadId: ThreadId | null | undefined) =>
1499− (state: AppState): Thread | undefined =>
1500− threadId ? getThread(state, threadId) : undefined;
1501−export const selectSidebarThreadSummaryById =
1502− (threadId: ThreadId | null | undefined) =>
1503− (state: AppState): SidebarThreadSummary | undefined =>
1504− threadId ? state.sidebarThreadSummaryById[threadId] : undefined;
1505−export const selectThreadIdsByProjectId =
1506− (projectId: ProjectId | null | undefined) =>
1507− (state: AppState): ThreadId[] =>
1508− projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS;
1585+ const currentEnvironmentState = getStoredEnvironmentState(state, environmentId);
1586+ const nextEnvironmentState = events.reduce(
1587+ (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId),
1588+ currentEnvironmentState,
1589+ );
1590+ return commitEnvironmentState(state, environmentId, nextEnvironmentState);
1591+}
15091592
1510−export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
1511− return updateThreadState(state, threadId, (thread) => {
1512− if (thread.error === error) return thread;
1513− return { ...thread, error };
1593+function getEnvironmentEntries(
1594+ state: AppState,
1595+): ReadonlyArray<readonly [EnvironmentId, EnvironmentState]> {
1596+ return Object.entries(state.environmentStateById) as unknown as ReadonlyArray<
1597+ readonly [EnvironmentId, EnvironmentState]
1598+ >;
1599+}
1600+
1601+export function selectEnvironmentState(
1602+ state: AppState,
1603+ environmentId: EnvironmentId | null | undefined,
1604+): EnvironmentState {
1605+ return environmentId ? getStoredEnvironmentState(state, environmentId) : initialEnvironmentState;
1606+}
1607+
1608+export function selectProjectsForEnvironment(
1609+ state: AppState,
1610+ environmentId: EnvironmentId | null | undefined,
1611+): Project[] {
1612+ return getProjects(selectEnvironmentState(state, environmentId));
1613+}
1614+
1615+export function selectThreadsForEnvironment(
1616+ state: AppState,
1617+ environmentId: EnvironmentId | null | undefined,
1618+): Thread[] {
1619+ return getThreads(selectEnvironmentState(state, environmentId));
1620+}
1621+
1622+export function selectProjectsAcrossEnvironments(state: AppState): Project[] {
1623+ return getEnvironmentEntries(state).flatMap(([, environmentState]) =>
1624+ getProjects(environmentState),
1625+ );
1626+}
1627+
1628+export function selectThreadsAcrossEnvironments(state: AppState): Thread[] {
1629+ return getEnvironmentEntries(state).flatMap(([, environmentState]) =>
1630+ getThreads(environmentState),
1631+ );
1632+}
1633+
1634+export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] {
1635+ return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) =>
1636+ environmentState.threadIds.flatMap((threadId) => {
1637+ const thread = environmentState.sidebarThreadSummaryById[threadId];
1638+ return thread && thread.environmentId === environmentId ? [thread] : [];
1639+ }),
1640+ );
1641+}
1642+
1643+export function selectSidebarThreadsForProjectRef(
1644+ state: AppState,
1645+ ref: ScopedProjectRef | null | undefined,
1646+): SidebarThreadSummary[] {
1647+ if (!ref) {
1648+ return [];
1649+ }
1650+
1651+ const environmentState = selectEnvironmentState(state, ref.environmentId);
1652+ const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? EMPTY_THREAD_IDS;
1653+ return threadIds.flatMap((threadId) => {
1654+ const thread = environmentState.sidebarThreadSummaryById[threadId];
1655+ return thread ? [thread] : [];
15141656 });
15151657 }
15161658
1659+export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean {
1660+ return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete;
1661+}
1662+
1663+export function selectProjectByRef(
1664+ state: AppState,
1665+ ref: ScopedProjectRef | null | undefined,
1666+): Project | undefined {
1667+ return ref
1668+ ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId]
1669+ : undefined;
1670+}
1671+
1672+export function selectThreadByRef(
1673+ state: AppState,
1674+ ref: ScopedThreadRef | null | undefined,
1675+): Thread | undefined {
1676+ return ref
1677+ ? getThread(selectEnvironmentState(state, ref.environmentId), ref.threadId)
1678+ : undefined;
1679+}
1680+
1681+export function selectSidebarThreadSummaryByRef(
1682+ state: AppState,
1683+ ref: ScopedThreadRef | null | undefined,
1684+): SidebarThreadSummary | undefined {
1685+ return ref
1686+ ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId]
1687+ : undefined;
1688+}
1689+
1690+export function selectThreadIdsByProjectRef(
1691+ state: AppState,
1692+ ref: ScopedProjectRef | null | undefined,
1693+): ThreadId[] {
1694+ return ref
1695+ ? (selectEnvironmentState(state, ref.environmentId).threadIdsByProjectId[ref.projectId] ??
1696+ EMPTY_THREAD_IDS)
1697+ : EMPTY_THREAD_IDS;
1698+}
1699+
1700+export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
1701+ if (state.activeEnvironmentId === null) {
1702+ return state;
1703+ }
1704+
1705+ const nextEnvironmentState = updateThreadState(
1706+ getStoredEnvironmentState(state, state.activeEnvironmentId),
1707+ threadId,
1708+ (thread) => {
1709+ if (thread.error === error) return thread;
1710+ return { ...thread, error };
1711+ },
1712+ );
1713+ return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState);
1714+}
1715+
1716+export function applyOrchestrationEvent(
1717+ state: AppState,
1718+ event: OrchestrationEvent,
1719+ environmentId: EnvironmentId,
1720+): AppState {
1721+ return commitEnvironmentState(
1722+ state,
1723+ environmentId,
1724+ applyEnvironmentOrchestrationEvent(
1725+ getStoredEnvironmentState(state, environmentId),
1726+ event,
1727+ environmentId,
1728+ ),
1729+ );
1730+}
1731+
1732+export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState {
1733+ if (state.activeEnvironmentId === environmentId) {
1734+ return state;
1735+ }
1736+
1737+ return {
1738+ ...state,
1739+ activeEnvironmentId: environmentId,
1740+ };
1741+}
1742+
15171743 export function setThreadBranch(
15181744 state: AppState,
15191745 threadId: ThreadId,
15201746 branch: string | null,
15211747 worktreePath: string | null,
15221748 ): AppState {
1523− return updateThreadState(state, threadId, (thread) => {
1524− if (thread.branch === branch && thread.worktreePath === worktreePath) return thread;
1525− const cwdChanged = thread.worktreePath !== worktreePath;
1526− return {
1527− ...thread,
1528− branch,
1529− worktreePath,
1530− ...(cwdChanged ? { session: null } : {}),
1531− };
1532− });
1749+ if (state.activeEnvironmentId === null) {
1750+ return state;
1751+ }
1752+
1753+ const nextEnvironmentState = updateThreadState(
1754+ getStoredEnvironmentState(state, state.activeEnvironmentId),
1755+ threadId,
1756+ (thread) => {
1757+ if (thread.branch === branch && thread.worktreePath === worktreePath) return thread;
1758+ const cwdChanged = thread.worktreePath !== worktreePath;
1759+ return {
1760+ ...thread,
1761+ branch,
1762+ worktreePath,
1763+ ...(cwdChanged ? { session: null } : {}),
1764+ };
1765+ },
1766+ );
1767+ return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState);
15331768 }
15341769
15351770 interface AppStore extends AppState {
1536− syncServerReadModel: (readModel: OrchestrationReadModel) => void;
1537− applyOrchestrationEvent: (event: OrchestrationEvent) => void;
1538− applyOrchestrationEvents: (events: ReadonlyArray<OrchestrationEvent>) => void;
1771+ setActiveEnvironmentId: (environmentId: EnvironmentId) => void;
1772+ syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void;
1773+ applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void;
1774+ applyOrchestrationEvents: (
1775+ events: ReadonlyArray<OrchestrationEvent>,
1776+ environmentId: EnvironmentId,
1777+ ) => void;
15391778 setError: (threadId: ThreadId, error: string | null) => void;
15401779 setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void;
15411780 }
15421781
15431782 export const useStore = create<AppStore>((set) => ({
15441783 ...initialState,
1545− syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)),
1546− applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)),
1547− applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)),
1784+ setActiveEnvironmentId: (environmentId) =>
1785+ set((state) => setActiveEnvironmentId(state, environmentId)),
1786+ syncServerReadModel: (readModel, environmentId) =>
1787+ set((state) => syncServerReadModel(state, readModel, environmentId)),
1788+ applyOrchestrationEvent: (event, environmentId) =>
1789+ set((state) => applyOrchestrationEvent(state, event, environmentId)),
1790+ applyOrchestrationEvents: (events, environmentId) =>
1791+ set((state) => applyOrchestrationEvents(state, events, environmentId)),
15481792 setError: (threadId, error) => set((state) => setError(state, threadId, error)),
15491793 setThreadBranch: (threadId, branch, worktreePath) =>
15501794 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
apps/web/src/storeSelectors.ts+68−27
@@ -1,5 +1,11 @@
1−import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts";
2−import { type AppState } from "./store";
1+import {
2+ type MessageId,
3+ type ScopedProjectRef,
4+ type ScopedThreadRef,
5+ type ThreadId,
6+ type TurnId,
7+} from "@t3tools/contracts";
8+import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store";
39 import {
410 type ChatMessage,
511 type Project,
@@ -30,54 +36,61 @@ function collectByIds<TKey extends string, TValue>( · 24 unmodified lines
3036 });
3137 }
3238
33−export function createProjectSelector(
34− projectId: ProjectId | null | undefined,
39+export function createProjectSelectorByRef(
40+ ref: ScopedProjectRef | null | undefined,
3541 ): (state: AppState) => Project | undefined {
36− return (state) => (projectId ? state.projectById[projectId] : undefined);
42+ return (state) =>

108 more lines aren’t shown here.

apps/web/src/terminalStateStore.test.ts+130−75

This diff is large: its 379 lines aren’t shown here.

apps/web/src/terminalStateStore.ts+165−127

This diff is large: its 509 lines aren’t shown here.

apps/web/src/threadRoutes.test.ts+67−0

This diff is large: its 68 lines aren’t shown here.

apps/web/src/threadRoutes.ts+59−0

This diff is large: its 60 lines aren’t shown here.

apps/web/src/threadSelectionStore.test.ts+53−53

This diff is large: its 260 lines aren’t shown here.

apps/web/src/threadSelectionStore.ts+51−53

This diff is large: its 173 lines aren’t shown here.

apps/web/src/types.ts+7−0

This diff is large: its 45 lines aren’t shown here.

apps/web/src/uiStateStore.test.ts+11−11

This diff is large: its 63 lines aren’t shown here.

apps/web/src/uiStateStore.ts+30−35

This diff is large: its 203 lines aren’t shown here.

apps/web/src/vite-env.d.ts+2−2

This diff is large: its 17 lines aren’t shown here.

apps/web/src/worktreeCleanup.test.ts+4−1

This diff is large: its 17 lines aren’t shown here.

apps/web/src/wsNativeApi.ts+0−117

This diff is large: its 118 lines aren’t shown here.

apps/web/src/wsRpcClient.test.ts+38−2

This diff is large: its 60 lines aren’t shown here.

apps/web/src/wsRpcClient.ts+153−12

This diff is large: its 201 lines aren’t shown here.

apps/web/src/wsTransport.test.ts+21−0

This diff is large: its 42 lines aren’t shown here.

bun.lock+15−0

This diff is large: its 36 lines aren’t shown here.

packages/client-runtime/package.json+25−0

This diff is large: its 26 lines aren’t shown here.

packages/client-runtime/src/index.ts+2−0

This diff is large: its 3 lines aren’t shown here.

packages/client-runtime/src/knownEnvironment.test.ts+63−0

This diff is large: its 64 lines aren’t shown here.

packages/client-runtime/src/knownEnvironment.ts+39−0

This diff is large: its 40 lines aren’t shown here.

packages/client-runtime/src/scoped.ts+64−0

This diff is large: its 65 lines aren’t shown here.

packages/client-runtime/tsconfig.json+4−0

This diff is large: its 5 lines aren’t shown here.

packages/contracts/src/baseSchemas.ts+2−0

This diff is large: its 9 lines aren’t shown here.

packages/contracts/src/environment.ts+77−0

This diff is large: its 78 lines aren’t shown here.

packages/contracts/src/index.ts+1−0

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/ipc.ts+46−20

This diff is large: its 108 lines aren’t shown here.

packages/contracts/src/orchestration.ts+4−0

This diff is large: its 31 lines aren’t shown here.

packages/contracts/src/server.ts+4−0

This diff is large: its 27 lines aren’t shown here.

packages/shared/src/git.test.ts+48−1

This diff is large: its 56 lines aren’t shown here.

packages/shared/src/git.ts+50−0

This diff is large: its 57 lines aren’t shown here.

scripts/release-smoke.ts+1−0

This diff is large: its 8 lines aren’t shown here.