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
60const UPDATE_INSTALL_CHANNEL = "desktop:update-install";60const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
61const UPDATE_CHECK_CHANNEL = "desktop:update-check";61const UPDATE_CHECK_CHANNEL = "desktop:update-check";
62const GET_WS_URL_CHANNEL = "desktop:get-ws-url";62const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
63const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
63const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");64const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
64const STATE_DIR = Path.join(BASE_DIR, "userdata");65const STATE_DIR = Path.join(BASE_DIR, "userdata");
65const DESKTOP_SCHEME = "t3";66const DESKTOP_SCHEME = "t3";
@@ -1172,6 +1173,14 @@ function registerIpcHandlers(): void { · 1106 unmodified lines
1172 event.returnValue = backendWsUrl;1173 event.returnValue = backendWsUrl;
1173 });1174 });
1174 1175
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
1175 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);1184 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
1176 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {1185 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
1177 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;1186 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
13const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";13const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
14const UPDATE_INSTALL_CHANNEL = "desktop:update-install";14const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
15const GET_WS_URL_CHANNEL = "desktop:get-ws-url";15const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
16const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
16 17
17contextBridge.exposeInMainWorld("desktopBridge", {18contextBridge.exposeInMainWorld("desktopBridge", {
18 getWsUrl: () => {19 getWsUrl: () => {
19 const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);20 const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
20 return typeof result === "string" ? result : null;21 return typeof result === "string" ? result : null;
21 },22 },
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 },
22 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),30 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
23 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),31 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
24 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),32 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
45import { ProviderService } from "../src/provider/Services/ProviderService.ts";45import { ProviderService } from "../src/provider/Services/ProviderService.ts";
46import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";46import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
47import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";47import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
48import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts";
48import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";49import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
49import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";50import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
50import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";51import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
@@ -338,6 +339,7 @@ export const makeOrchestrationIntegrationHarness = ( · 287 unmodified lines
338 Layer.provideMerge(runtimeServicesLayer),339 Layer.provideMerge(runtimeServicesLayer),
339 Layer.provideMerge(orchestrationReactorLayer),340 Layer.provideMerge(orchestrationReactorLayer),
340 Layer.provide(persistenceLayer),341 Layer.provide(persistenceLayer),
342 Layer.provideMerge(RepositoryIdentityResolverLive),
341 Layer.provideMerge(ServerSettingsService.layerTest()),343 Layer.provideMerge(ServerSettingsService.layerTest()),
342 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),344 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
343 Layer.provideMerge(NodeServices.layer),345 Layer.provideMerge(NodeServices.layer),
apps/server/src/config.ts+2−0
@@ -30,6 +30,7 @@ export interface ServerDerivedPaths { · 29 unmodified lines
30 readonly providerEventLogPath: string;30 readonly providerEventLogPath: string;
31 readonly terminalLogsDir: string;31 readonly terminalLogsDir: string;
32 readonly anonymousIdPath: string;32 readonly anonymousIdPath: string;
33 readonly environmentIdPath: string;
33}34}
34 35
35/**36/**
@@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* ( · 47 unmodified lines
83 providerEventLogPath: join(providerLogsDir, "events.log"),84 providerEventLogPath: join(providerLogsDir, "events.log"),
84 terminalLogsDir: join(logsDir, "terminals"),85 terminalLogsDir: join(logsDir, "terminals"),
85 anonymousIdPath: join(stateDir, "anonymous-id"),86 anonymousIdPath: join(stateDir, "anonymous-id"),
87 environmentIdPath: join(stateDir, "environment-id"),
86 };88 };
87});89});
88 90
apps/server/src/environment/Layers/ServerEnvironment.test.ts+124−0
@@ -0,0 +1,124 @@
1import * as nodePath from "node:path";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, it } from "@effect/vitest";
4import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect";
5
6import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts";
7import { ServerEnvironment } from "../Services/ServerEnvironment.ts";
8import { ServerEnvironmentLive } from "./ServerEnvironment.ts";
9
10const makeServerEnvironmentLayer = (baseDir: string) =>
11 ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
12
13const 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
42it.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 @@
1import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2import { Effect, FileSystem, Layer, Path, Random } from "effect";
3
4import { ServerConfig } from "../../config.ts";
5import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";
6import { version } from "../../../package.json" with { type: "json" };
7
8function 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
21function 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
32export 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
94export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment());
apps/server/src/environment/Services/ServerEnvironment.ts+13−0
@@ -0,0 +1,13 @@
1import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2import { ServiceMap } from "effect";
3import type { Effect } from "effect";
4
5export interface ServerEnvironmentShape {
6 readonly getEnvironmentId: Effect.Effect<EnvironmentId>;
7 readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>;
8}
9
10export 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
854 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",854 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
855 );855 );
856 }),856 }),
857 12_000,857 20_000,
858 );858 );
859 859
860 it.effect(860 it.effect(
@@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 101 unmodified lines
962 ),962 ),
963 ).toBe(false);963 ).toBe(false);
964 }),964 }),
965 12_000,965 20_000,
966 );966 );
967 967
968 it.effect("status returns merged PR state when latest PR was merged", () =>968 it.effect("status returns merged PR state when latest PR was merged", () =>
@@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 716 unmodified lines
1685 false,1685 false,
1686 );1686 );
1687 }),1687 }),
1688 12_000,1688 20_000,
1689 );1689 );
1690 1690
1691 it.effect(1691 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
20import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";20import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
21import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";21import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
22import { GitCoreLive } from "../../git/Layers/GitCore.ts";22import { GitCoreLive } from "../../git/Layers/GitCore.ts";
23import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
23import { CheckpointReactorLive } from "./CheckpointReactor.ts";24import { CheckpointReactorLive } from "./CheckpointReactor.ts";
24import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";25import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
25import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";26import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
@@ -256,6 +257,7 @@ describe("CheckpointReactor", () => { · 230 unmodified lines
256 Layer.provide(OrchestrationProjectionPipelineLive),257 Layer.provide(OrchestrationProjectionPipelineLive),
257 Layer.provide(OrchestrationEventStoreLive),258 Layer.provide(OrchestrationEventStoreLive),
258 Layer.provide(OrchestrationCommandReceiptRepositoryLive),259 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
260 Layer.provide(RepositoryIdentityResolverLive),
259 Layer.provide(SqlitePersistenceMemory),261 Layer.provide(SqlitePersistenceMemory),
260 );262 );
261 263
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
19 OrchestrationEventStore,19 OrchestrationEventStore,
20 type OrchestrationEventStoreShape,20 type OrchestrationEventStoreShape,
21} from "../../persistence/Services/OrchestrationEventStore.ts";21} from "../../persistence/Services/OrchestrationEventStore.ts";
22import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
22import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";23import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
23import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";24import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
24import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";25import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -45,6 +46,7 @@ async function createOrchestrationSystem() { · 20 unmodified lines
45 Layer.provide(OrchestrationProjectionPipelineLive),46 Layer.provide(OrchestrationProjectionPipelineLive),
46 Layer.provide(OrchestrationEventStoreLive),47 Layer.provide(OrchestrationEventStoreLive),
47 Layer.provide(OrchestrationCommandReceiptRepositoryLive),48 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
49 Layer.provide(RepositoryIdentityResolverLive),
48 Layer.provide(SqlitePersistenceMemory),50 Layer.provide(SqlitePersistenceMemory),
49 Layer.provideMerge(ServerConfigLayer),51 Layer.provideMerge(ServerConfigLayer),
50 Layer.provideMerge(NodeServices.layer),52 Layer.provideMerge(NodeServices.layer),
@@ -623,6 +625,7 @@ describe("OrchestrationEngine", () => { · 572 unmodified lines
623 Layer.provide(OrchestrationProjectionPipelineLive),625 Layer.provide(OrchestrationProjectionPipelineLive),
624 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),626 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
625 Layer.provide(OrchestrationCommandReceiptRepositoryLive),627 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
628 Layer.provide(RepositoryIdentityResolverLive),
626 Layer.provide(SqlitePersistenceMemory),629 Layer.provide(SqlitePersistenceMemory),
627 Layer.provideMerge(ServerConfigLayer),630 Layer.provideMerge(ServerConfigLayer),
628 Layer.provideMerge(NodeServices.layer),631 Layer.provideMerge(NodeServices.layer),
@@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => { · 90 unmodified lines
719 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),722 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
720 Layer.provide(OrchestrationEventStoreLive),723 Layer.provide(OrchestrationEventStoreLive),
721 Layer.provide(OrchestrationCommandReceiptRepositoryLive),724 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
725 Layer.provide(RepositoryIdentityResolverLive),
722 Layer.provide(SqlitePersistenceMemory),726 Layer.provide(SqlitePersistenceMemory),
723 ),727 ),
724 );728 );
@@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => { · 136 unmodified lines
861 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),865 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
862 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),866 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
863 Layer.provide(OrchestrationCommandReceiptRepositoryLive),867 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
868 Layer.provide(RepositoryIdentityResolverLive),
864 Layer.provide(SqlitePersistenceMemory),869 Layer.provide(SqlitePersistenceMemory),
865 ),870 ),
866 );871 );
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2−0
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
20 SqlitePersistenceMemory,20 SqlitePersistenceMemory,
21} from "../../persistence/Layers/Sqlite.ts";21} from "../../persistence/Layers/Sqlite.ts";
22import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";22import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
23import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
23import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";24import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
24import {25import {
25 ORCHESTRATION_PROJECTOR_NAMES,26 ORCHESTRATION_PROJECTOR_NAMES,
@@ -1846,6 +1847,7 @@ const engineLayer = it.layer( · 1820 unmodified lines
1846 Layer.provide(OrchestrationProjectionPipelineLive),1847 Layer.provide(OrchestrationProjectionPipelineLive),
1847 Layer.provide(OrchestrationEventStoreLive),1848 Layer.provide(OrchestrationEventStoreLive),
1848 Layer.provide(OrchestrationCommandReceiptRepositoryLive),1849 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
1850 Layer.provide(RepositoryIdentityResolverLive),
1849 Layer.provideMerge(SqlitePersistenceMemory),1851 Layer.provideMerge(SqlitePersistenceMemory),
1850 Layer.provideMerge(1852 Layer.provideMerge(
1851 ServerConfig.layerTest(process.cwd(), {1853 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
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
5 5
6import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";6import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
7import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
7import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";8import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
8import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";9import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
9import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";10import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
@@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value); · 5 unmodified lines
15const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);16const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
16 17
17const projectionSnapshotLayer = it.layer(18const projectionSnapshotLayer = it.layer(
18 OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),19 OrchestrationProjectionSnapshotQueryLive.pipe(
20 Layer.provideMerge(RepositoryIdentityResolverLive),
21 Layer.provideMerge(SqlitePersistenceMemory),
22 ),
19);23);
20 24
21projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {25projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
@@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 212 unmodified lines
234 id: asProjectId("project-1"),238 id: asProjectId("project-1"),
235 title: "Project 1",239 title: "Project 1",
236 workspaceRoot: "/tmp/project-1",240 workspaceRoot: "/tmp/project-1",
241 repositoryIdentity: null,
237 defaultModelSelection: {242 defaultModelSelection: {
238 provider: "codex",243 provider: "codex",
239 model: "gpt-5-codex",244 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
38import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";38import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
39import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";39import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
40import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";40import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
41import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
41import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";42import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
42import {43import {
43 ProjectionSnapshotQuery,44 ProjectionSnapshotQuery,
@@ -163,6 +164,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st · 119 unmodified lines
163 164
164const makeProjectionSnapshotQuery = Effect.gen(function* () {165const makeProjectionSnapshotQuery = Effect.gen(function* () {
165 const sql = yield* SqlClient.SqlClient;166 const sql = yield* SqlClient.SqlClient;
167 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
168 const repositoryIdentityResolutionConcurrency = 4;
166 169
167 const listProjectRows = SqlSchema.findAll({170 const listProjectRows = SqlSchema.findAll({
168 Request: Schema.Void,171 Request: Schema.Void,
@@ -436,269 +439,283 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 267 unmodified lines
436 const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () =>439 const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () =>
437 sql440 sql
438 .withTransaction(441 .withTransaction(
439 Effect.gen(function* () {442 Effect.all([
440 const [443 listProjectRows(undefined).pipe(
441 projectRows,444 Effect.mapError(
442 threadRows,445 toPersistenceSqlOrDecodeError(
443 messageRows,446 "ProjectionSnapshotQuery.getSnapshot:listProjects:query",
444 proposedPlanRows,447 "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows",
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 ),
465 ),448 ),
466 ),449 ),
467 listThreadMessageRows(undefined).pipe(450 ),
468 Effect.mapError(451 listThreadRows(undefined).pipe(
469 toPersistenceSqlOrDecodeError(452 Effect.mapError(
470 "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query",453 toPersistenceSqlOrDecodeError(
471 "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows",454 "ProjectionSnapshotQuery.getSnapshot:listThreads:query",
472 ),455 "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows",
473 ),456 ),
474 ),457 ),
475 listThreadProposedPlanRows(undefined).pipe(458 ),
476 Effect.mapError(459 listThreadMessageRows(undefined).pipe(
477 toPersistenceSqlOrDecodeError(460 Effect.mapError(
478 "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query",461 toPersistenceSqlOrDecodeError(
479 "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows",462 "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query",
480 ),463 "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows",
481 ),464 ),
482 ),465 ),
483 listThreadActivityRows(undefined).pipe(466 ),
484 Effect.mapError(467 listThreadProposedPlanRows(undefined).pipe(
485 toPersistenceSqlOrDecodeError(468 Effect.mapError(
486 "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query",469 toPersistenceSqlOrDecodeError(
487 "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows",470 "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query",
488 ),471 "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows",
489 ),472 ),
490 ),473 ),
491 listThreadSessionRows(undefined).pipe(474 ),
492 Effect.mapError(475 listThreadActivityRows(undefined).pipe(
493 toPersistenceSqlOrDecodeError(476 Effect.mapError(
494 "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query",477 toPersistenceSqlOrDecodeError(
495 "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows",478 "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query",
496 ),479 "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows",
497 ),480 ),
498 ),481 ),
499 listCheckpointRows(undefined).pipe(482 ),
500 Effect.mapError(483 listThreadSessionRows(undefined).pipe(
501 toPersistenceSqlOrDecodeError(484 Effect.mapError(
502 "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query",485 toPersistenceSqlOrDecodeError(
503 "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows",486 "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query",
504 ),487 "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows",
505 ),488 ),
506 ),489 ),
507 listLatestTurnRows(undefined).pipe(490 ),
508 Effect.mapError(491 listCheckpointRows(undefined).pipe(
509 toPersistenceSqlOrDecodeError(492 Effect.mapError(
510 "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query",493 toPersistenceSqlOrDecodeError(
511 "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows",494 "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query",
512 ),495 "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows",
513 ),496 ),
514 ),497 ),
515 listProjectionStateRows(undefined).pipe(498 ),
516 Effect.mapError(499 listLatestTurnRows(undefined).pipe(
517 toPersistenceSqlOrDecodeError(500 Effect.mapError(
518 "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query",501 toPersistenceSqlOrDecodeError(
519 "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows",502 "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query",
520 ),503 "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows",
521 ),504 ),
522 ),505 ),
523 ]);506 ),
524 507 listProjectionStateRows(undefined).pipe(
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(
695 Effect.mapError(508 Effect.mapError(
696 toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"),509 toPersistenceSqlOrDecodeError(
510 "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query",
511 "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows",
512 ),
697 ),513 ),
698 );514 ),
699 }),515 ]),
700 )516 )
701 .pipe(517 .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 ),
702 Effect.mapError((error) => {719 Effect.mapError((error) => {
703 if (isPersistenceError(error)) {720 if (isPersistenceError(error)) {
704 return error;721 return error;
@@ -732,19 +749,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 27 unmodified lines
732 "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow",749 "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow",
733 ),750 ),
734 ),751 ),
735 Effect.map(752 Effect.flatMap((option) =>
736 Option.map(753 Option.isNone(option)
737 (row): OrchestrationProject => ({754 ? Effect.succeed(Option.none<OrchestrationProject>())
738 id: row.projectId,755 : repositoryIdentityResolver.resolve(option.value.workspaceRoot).pipe(
739 title: row.title,756 Effect.map((repositoryIdentity) =>
740 workspaceRoot: row.workspaceRoot,757 Option.some({
741 defaultModelSelection: row.defaultModelSelection,758 id: option.value.projectId,
742 scripts: row.scripts,759 title: option.value.title,
743 createdAt: row.createdAt,760 workspaceRoot: option.value.workspaceRoot,
744 updatedAt: row.updatedAt,761 repositoryIdentity,
745 deletedAt: row.deletedAt,762 defaultModelSelection: option.value.defaultModelSelection,
746 }),763 scripts: option.value.scripts,
747 ),764 createdAt: option.value.createdAt,
765 updatedAt: option.value.updatedAt,
766 deletedAt: option.value.deletedAt,
767 } satisfies OrchestrationProject),
768 ),
769 ),
748 ),770 ),
749 );771 );
750 772
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+2−0
@@ -28,6 +28,7 @@ import { · 27 unmodified lines
28} from "../../provider/Services/ProviderService.ts";28} from "../../provider/Services/ProviderService.ts";
29import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";29import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";
30import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";30import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";
31import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
31import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";32import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
32import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";33import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
33import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";34import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => { · 184 unmodified lines
218 Layer.provide(OrchestrationProjectionPipelineLive),219 Layer.provide(OrchestrationProjectionPipelineLive),
219 Layer.provide(OrchestrationEventStoreLive),220 Layer.provide(OrchestrationEventStoreLive),
220 Layer.provide(OrchestrationCommandReceiptRepositoryLive),221 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
222 Layer.provide(RepositoryIdentityResolverLive),
221 Layer.provide(SqlitePersistenceMemory),223 Layer.provide(SqlitePersistenceMemory),
222 );224 );
223 const layer = ProviderCommandReactorLive.pipe(225 const layer = ProviderCommandReactorLive.pipe(
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+2−0
@@ -29,6 +29,7 @@ import { · 28 unmodified lines
29 ProviderService,29 ProviderService,
30 type ProviderServiceShape,30 type ProviderServiceShape,
31} from "../../provider/Services/ProviderService.ts";31} from "../../provider/Services/ProviderService.ts";
32import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
32import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";33import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
33import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";34import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
34import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";35import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => { · 169 unmodified lines
204 Layer.provide(OrchestrationProjectionPipelineLive),205 Layer.provide(OrchestrationProjectionPipelineLive),
205 Layer.provide(OrchestrationEventStoreLive),206 Layer.provide(OrchestrationEventStoreLive),
206 Layer.provide(OrchestrationCommandReceiptRepositoryLive),207 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
208 Layer.provide(RepositoryIdentityResolverLive),
207 Layer.provide(SqlitePersistenceMemory),209 Layer.provide(SqlitePersistenceMemory),
208 );210 );
209 const layer = ProviderRuntimeIngestionLive.pipe(211 const layer = ProviderRuntimeIngestionLive.pipe(
apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts+193−0
@@ -0,0 +1,193 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Duration, Effect, FileSystem, Layer } from "effect";
4import { TestClock } from "effect/testing";
5
6import { runProcess } from "../../processRunner.ts";
7import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts";
8import {
9 makeRepositoryIdentityResolver,
10 RepositoryIdentityResolverLive,
11} from "./RepositoryIdentityResolver.ts";
12
13const git = (cwd: string, args: ReadonlyArray<string>) =>
14 Effect.promise(() => runProcess("git", ["-C", cwd, ...args]));
15
16const 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
28it.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 @@
1import type { RepositoryIdentity } from "@t3tools/contracts";
2import { Cache, Duration, Effect, Exit, Layer } from "effect";
3import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git";
4
5import { runProcess } from "../../processRunner.ts";
6import {
7 RepositoryIdentityResolver,
8 type RepositoryIdentityResolverShape,
9} from "../Services/RepositoryIdentityResolver.ts";
10
11function 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
27function 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
42function 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
67const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
68const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);
69const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10);
70
71interface RepositoryIdentityResolverOptions {
72 readonly cacheCapacity?: number;
73 readonly positiveCacheTtl?: Duration.Input;
74 readonly negativeCacheTtl?: Duration.Input;
75}
76
77async 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
99async 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
117export 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
144export const RepositoryIdentityResolverLive = Layer.effect(
145 RepositoryIdentityResolver,
146 makeRepositoryIdentityResolver(),
147);
apps/server/src/project/Services/RepositoryIdentityResolver.ts+12−0
@@ -0,0 +1,12 @@
1import type { RepositoryIdentity } from "@t3tools/contracts";
2import { ServiceMap } from "effect";
3import type { Effect } from "effect";
4
5export interface RepositoryIdentityResolverShape {
6 readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>;
7}
8
9export 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
5import {5import {
6 CommandId,6 CommandId,
7 DEFAULT_SERVER_SETTINGS,7 DEFAULT_SERVER_SETTINGS,
8 EnvironmentId,
9 EventId,
8 GitCommandError,10 GitCommandError,
9 KeybindingRule,11 KeybindingRule,
10 MessageId,12 MessageId,
@@ -83,6 +85,14 @@ import { · 72 unmodified lines
83 ProjectSetupScriptRunner,85 ProjectSetupScriptRunner,
84 type ProjectSetupScriptRunnerShape,86 type ProjectSetupScriptRunnerShape,
85} from "./project/Services/ProjectSetupScriptRunner.ts";87} from "./project/Services/ProjectSetupScriptRunner.ts";
88import {
89 RepositoryIdentityResolver,
90 type RepositoryIdentityResolverShape,
91} from "./project/Services/RepositoryIdentityResolver.ts";
92import {
93 ServerEnvironment,
94 type ServerEnvironmentShape,
95} from "./environment/Services/ServerEnvironment.ts";
86import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";96import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
87import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";97import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
88import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";98import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
@@ -93,6 +103,18 @@ const defaultModelSelection = { · 4 unmodified lines
93 provider: "codex",103 provider: "codex",
94 model: "gpt-5-codex",104 model: "gpt-5-codex",
95} as const;105} as const;
106const 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};
96 118
97const makeDefaultOrchestrationReadModel = () => {119const makeDefaultOrchestrationReadModel = () => {
98 const now = new Date().toISOString();120 const now = new Date().toISOString();
@@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: { · 171 unmodified lines
270 browserTraceCollector?: Partial<BrowserTraceCollectorShape>;292 browserTraceCollector?: Partial<BrowserTraceCollectorShape>;
271 serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>;293 serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>;
272 serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>;294 serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>;
295 serverEnvironment?: Partial<ServerEnvironmentShape>;
296 repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>;
273 };297 };
274}) =>298}) =>
275 Effect.gen(function* () {299 Effect.gen(function* () {
@@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: { · 140 unmodified lines
416 ...options?.layers?.serverRuntimeStartup,440 ...options?.layers?.serverRuntimeStartup,
417 }),441 }),
418 ),442 ),
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 ),
419 Layer.provide(workspaceAndProjectServicesLayer),456 Layer.provide(workspaceAndProjectServicesLayer),
420 Layer.provideMerge(FetchHttpClient.layer),457 Layer.provideMerge(FetchHttpClient.layer),
421 Layer.provide(layerConfig),458 Layer.provide(layerConfig),
@@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 647 unmodified lines
1069 sequence: 1,1106 sequence: 1,
1070 type: "welcome" as const,1107 type: "welcome" as const,
1071 payload: {1108 payload: {
1109 environment: testEnvironmentDescriptor,
1072 cwd: "/tmp/project",1110 cwd: "/tmp/project",
1073 projectName: "project",1111 projectName: "project",
1074 },1112 },
@@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 3 unmodified lines
1078 version: 1 as const,1116 version: 1 as const,
1079 sequence: 2,1117 sequence: 2,
1080 type: "ready" as const,1118 type: "ready" as const,
1081 payload: { at: new Date().toISOString() },1119 payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor },
1082 });1120 });
1083 1121
1084 yield* buildAppUnderTest({1122 yield* buildAppUnderTest({
@@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 911 unmodified lines
1996 }).pipe(Effect.provide(NodeHttpServer.layerTest)),2034 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1997 );2035 );
1998 2036
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
1999 it.effect("closes thread terminals after a successful archive command", () =>2104 it.effect("closes thread terminals after a successful archive command", () =>
2000 Effect.gen(function* () {2105 Effect.gen(function* () {
2001 const threadId = ThreadId.makeUnsafe("thread-archive");2106 const threadId = ThreadId.makeUnsafe("thread-archive");
@@ -2498,6 +2603,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 496 unmodified lines
2498 }).pipe(Effect.provide(NodeHttpServer.layerTest)),2603 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2499 );2604 );
2500 2605
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
2501 it.effect("routes websocket rpc orchestration.getSnapshot errors", () =>2745 it.effect("routes websocket rpc orchestration.getSnapshot errors", () =>
2502 Effect.gen(function* () {2746 Effect.gen(function* () {
2503 yield* buildAppUnderTest({2747 yield* buildAppUnderTest({
apps/server/src/server.ts+4−0
@@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" · 43 unmodified lines
44import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";44import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
45import { ServerSettingsLive } from "./serverSettings";45import { ServerSettingsLive } from "./serverSettings";
46import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver";46import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver";
47import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver";
47import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries";48import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries";
48import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem";49import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem";
49import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";50import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";
50import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner";51import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner";
51import { ObservabilityLive } from "./observability/Layers/Observability";52import { ObservabilityLive } from "./observability/Layers/Observability";
53import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment";
52 54
53const PtyAdapterLive = Layer.unwrap(55const PtyAdapterLive = Layer.unwrap(
54 Effect.gen(function* () {56 Effect.gen(function* () {
@@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( · 144 unmodified lines
199 Layer.provideMerge(ServerSettingsLive),201 Layer.provideMerge(ServerSettingsLive),
200 Layer.provideMerge(WorkspaceLayerLive),202 Layer.provideMerge(WorkspaceLayerLive),
201 Layer.provideMerge(ProjectFaviconResolverLive),203 Layer.provideMerge(ProjectFaviconResolverLive),
204 Layer.provideMerge(RepositoryIdentityResolverLive),
205 Layer.provideMerge(ServerEnvironmentLive),
202 206
203 // Misc.207 // Misc.
204 Layer.provideMerge(AnalyticsServiceLayerLive),208 Layer.provideMerge(AnalyticsServiceLayerLive),
apps/server/src/serverLifecycleEvents.test.ts+10−0
@@ -1,3 +1,4 @@
1import { EnvironmentId } from "@t3tools/contracts";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
2import { assertTrue } from "@effect/vitest/utils";3import { assertTrue } from "@effect/vitest/utils";
3import { Effect, Option } from "effect";4import { Effect, Option } from "effect";
@@ -9,12 +10,20 @@ it.effect( · 5 unmodified lines
9 () =>10 () =>
10 Effect.gen(function* () {11 Effect.gen(function* () {
11 const lifecycleEvents = yield* ServerLifecycleEvents;12 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 };
12 20
13 const welcome = yield* lifecycleEvents21 const welcome = yield* lifecycleEvents
14 .publish({22 .publish({
15 version: 1,23 version: 1,
16 type: "welcome",24 type: "welcome",
17 payload: {25 payload: {
26 environment,
18 cwd: "/tmp/project",27 cwd: "/tmp/project",
19 projectName: "project",28 projectName: "project",
20 },29 },
@@ -29,6 +38,7 @@ it.effect( · 8 unmodified lines
29 type: "ready",38 type: "ready",
30 payload: {39 payload: {
31 at: new Date().toISOString(),40 at: new Date().toISOString(),
41 environment,
32 },42 },
33 })43 })
34 .pipe(Effect.timeoutOption("50 millis"));44 .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
27import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";27import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
28import { ServerLifecycleEvents } from "./serverLifecycleEvents";28import { ServerLifecycleEvents } from "./serverLifecycleEvents";
29import { ServerSettingsService } from "./serverSettings";29import { ServerSettingsService } from "./serverSettings";
30import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
30import { AnalyticsService } from "./telemetry/Services/AnalyticsService";31import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
31 32
32const isWildcardHost = (host: string | undefined): boolean =>33const isWildcardHost = (host: string | undefined): boolean =>
@@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 229 unmodified lines
262 const orchestrationReactor = yield* OrchestrationReactor;263 const orchestrationReactor = yield* OrchestrationReactor;
263 const lifecycleEvents = yield* ServerLifecycleEvents;264 const lifecycleEvents = yield* ServerLifecycleEvents;
264 const serverSettings = yield* ServerSettingsService;265 const serverSettings = yield* ServerSettingsService;
266 const serverEnvironment = yield* ServerEnvironment;
265 267
266 const commandGate = yield* makeCommandGate;268 const commandGate = yield* makeCommandGate;
267 const httpListening = yield* Deferred.make<void>();269 const httpListening = yield* Deferred.make<void>();
@@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 40 unmodified lines
308 310
309 yield* Effect.logDebug("startup phase: preparing welcome payload");311 yield* Effect.logDebug("startup phase: preparing welcome payload");
310 const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome);312 const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome);
313 const environment = yield* serverEnvironment.getDescriptor;
311 yield* Effect.logDebug("startup phase: publishing welcome event", {314 yield* Effect.logDebug("startup phase: publishing welcome event", {
315 environmentId: environment.environmentId,
312 cwd: welcome.cwd,316 cwd: welcome.cwd,
313 projectName: welcome.projectName,317 projectName: welcome.projectName,
314 bootstrapProjectId: welcome.bootstrapProjectId,318 bootstrapProjectId: welcome.bootstrapProjectId,
@@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 4 unmodified lines
319 lifecycleEvents.publish({323 lifecycleEvents.publish({
320 version: 1,324 version: 1,
321 type: "welcome",325 type: "welcome",
322 payload: welcome,326 payload: {
327 environment,
328 ...welcome,
329 },
323 }),330 }),
324 );331 );
325 }).pipe(332 }).pipe(
@@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 28 unmodified lines
354 lifecycleEvents.publish({361 lifecycleEvents.publish({
355 version: 1,362 version: 1,
356 type: "ready",363 type: "ready",
357 payload: { at: new Date().toISOString() },364 payload: {
365 at: new Date().toISOString(),
366 environment: yield* serverEnvironment.getDescriptor,
367 },
358 }),368 }),
359 );369 );
360 370
apps/server/src/ws.ts+55−1
@@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; · 46 unmodified lines
47import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem";47import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem";
48import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths";48import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths";
49import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner";49import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner";
50import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver";
51import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
50 52
51const WsRpcLayer = WsRpcGroup.toLayer(53const WsRpcLayer = WsRpcGroup.toLayer(
52 Effect.gen(function* () {54 Effect.gen(function* () {
@@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 14 unmodified lines
67 const workspaceEntries = yield* WorkspaceEntries;69 const workspaceEntries = yield* WorkspaceEntries;
68 const workspaceFileSystem = yield* WorkspaceFileSystem;70 const workspaceFileSystem = yield* WorkspaceFileSystem;
69 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;71 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
72 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
73 const serverEnvironment = yield* ServerEnvironment;
70 const serverCommandId = (tag: string) =>74 const serverCommandId = (tag: string) =>
71 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);75 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
72 76
@@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 40 unmodified lines
113 });117 });
114 };118 };
115 119
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
116 const dispatchBootstrapTurnStart = (163 const dispatchBootstrapTurnStart = (
117 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,164 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,
118 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>165 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>
@@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 210 unmodified lines
329 const keybindingsConfig = yield* keybindings.loadConfigState;376 const keybindingsConfig = yield* keybindings.loadConfigState;
330 const providers = yield* providerRegistry.getProviders;377 const providers = yield* providerRegistry.getProviders;
331 const settings = yield* serverSettings.getSettings;378 const settings = yield* serverSettings.getSettings;
379 const environment = yield* serverEnvironment.getDescriptor;
332 380
333 return {381 return {
382 environment,
334 cwd: config.cwd,383 cwd: config.cwd,
335 keybindingsConfigPath: config.keybindingsConfigPath,384 keybindingsConfigPath: config.keybindingsConfigPath,
336 keybindings: keybindingsConfig.keybindings,385 keybindings: keybindingsConfig.keybindings,
@@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 98 unmodified lines
435 ),484 ),
436 ).pipe(485 ).pipe(
437 Effect.map((events) => Array.from(events)),486 Effect.map((events) => Array.from(events)),
487 Effect.flatMap(enrichOrchestrationEvents),
438 Effect.mapError(488 Effect.mapError(
439 (cause) =>489 (cause) =>
440 new OrchestrationReplayEventsError({490 new OrchestrationReplayEventsError({
@@ -455,10 +505,14 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 14 unmodified lines
455 orchestrationEngine.readEvents(fromSequenceExclusive),505 orchestrationEngine.readEvents(fromSequenceExclusive),
456 ).pipe(506 ).pipe(
457 Effect.map((events) => Array.from(events)),507 Effect.map((events) => Array.from(events)),
508 Effect.flatMap(enrichOrchestrationEvents),
458 Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),509 Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),
459 );510 );
460 const replayStream = Stream.fromIterable(replayEvents);511 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);
462 type SequenceState = {516 type SequenceState = {
463 readonly nextSequence: number;517 readonly nextSequence: number;
464 readonly pendingBySequence: Map<number, OrchestrationEvent>;518 readonly pendingBySequence: Map<number, OrchestrationEvent>;
apps/web/index.html+1−0
@@ -12,6 +12,7 @@ · 11 unmodified lines
12 rel="stylesheet"12 rel="stylesheet"
13 />13 />
14 <title>T3 Code (Alpha)</title>14 <title>T3 Code (Alpha)</title>
15 <script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>
15 </head>16 </head>
16 <body>17 <body>
17 <div id="root"></div>18 <div id="root"></div>
apps/web/package.json+1−0
@@ -23,6 +23,7 @@ · 22 unmodified lines
23 "@formkit/auto-animate": "^0.9.0",23 "@formkit/auto-animate": "^0.9.0",
24 "@lexical/react": "^0.41.0",24 "@lexical/react": "^0.41.0",
25 "@pierre/diffs": "^1.1.0-beta.16",25 "@pierre/diffs": "^1.1.0-beta.16",
26 "@t3tools/client-runtime": "workspace:*",
26 "@t3tools/contracts": "workspace:*",27 "@t3tools/contracts": "workspace:*",
27 "@t3tools/shared": "workspace:*",28 "@t3tools/shared": "workspace:*",
28 "@tanstack/react-pacer": "^0.19.4",29 "@tanstack/react-pacer": "^0.19.4",
apps/web/src/components/BranchToolbar.tsx+36−14
@@ -1,11 +1,13 @@
1import type { ThreadId } from "@t3tools/contracts";1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { FolderIcon, GitForkIcon } from "lucide-react";3import { FolderIcon, GitForkIcon } from "lucide-react";
3import { useCallback } from "react";4import { useCallback, useMemo } from "react";
4 5
6import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
7import { readEnvironmentApi } from "../environmentApi";
5import { newCommandId } from "../lib/utils";8import { newCommandId } from "../lib/utils";
6import { readNativeApi } from "../nativeApi";
7import { useComposerDraftStore } from "../composerDraftStore";
8import { useStore } from "../store";9import { useStore } from "../store";
10import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
9import {11import {
10 EnvMode,12 EnvMode,
11 resolveDraftEnvModeAfterBranchChange,13 resolveDraftEnvModeAfterBranchChange,
@@ -20,7 +22,9 @@ const envModeItems = [ · 8 unmodified lines
20] as const;22] as const;
21 23
22interface BranchToolbarProps {24interface BranchToolbarProps {
25 environmentId: EnvironmentId;
23 threadId: ThreadId;26 threadId: ThreadId;
27 draftId?: DraftId;
24 onEnvModeChange: (mode: EnvMode) => void;28 onEnvModeChange: (mode: EnvMode) => void;
25 envLocked: boolean;29 envLocked: boolean;
26 onCheckoutPullRequestRequest?: (reference: string) => void;30 onCheckoutPullRequestRequest?: (reference: string) => void;
@@ -28,21 +32,34 @@ interface BranchToolbarProps { · 1 unmodified line
28}32}
29 33
30export default function BranchToolbar({34export default function BranchToolbar({
35 environmentId,
31 threadId,36 threadId,
37 draftId,
32 onEnvModeChange,38 onEnvModeChange,
33 envLocked,39 envLocked,
34 onCheckoutPullRequestRequest,40 onCheckoutPullRequestRequest,
35 onComposerFocusRequest,41 onComposerFocusRequest,
36}: BranchToolbarProps) {42}: BranchToolbarProps) {
37 const serverThread = useStore((store) => store.threadShellById[threadId]);43 const threadRef = useMemo(
38 const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null);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;
39 const setThreadBranchAction = useStore((store) => store.setThreadBranch);50 const setThreadBranchAction = useStore((store) => store.setThreadBranch);
40 const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));51 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
41 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);52 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
42 const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null;53 const activeProjectRef = serverThread
43 const activeProject = useStore((store) =>54 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
44 activeProjectId ? store.projectById[activeProjectId] : undefined,55 : draftThread
56 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
57 : null;
58 const activeProjectSelector = useMemo(
59 () => createProjectSelectorByRef(activeProjectRef),
60 [activeProjectRef],
45 );61 );
62 const activeProject = useStore(activeProjectSelector);
46 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);63 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
47 const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;64 const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
48 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;65 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
@@ -56,8 +73,8 @@ export default function BranchToolbar({ · 7 unmodified lines
56 73
57 const setThreadBranch = useCallback(74 const setThreadBranch = useCallback(
58 (branch: string | null, worktreePath: string | null) => {75 (branch: string | null, worktreePath: string | null) => {
59 if (!activeThreadId) return;76 if (!activeThreadId || !activeProject) return;
60 const api = readNativeApi();77 const api = readEnvironmentApi(environmentId);
61 // If the effective cwd is about to change, stop the running session so the78 // If the effective cwd is about to change, stop the running session so the
62 // next message creates a new one with the correct cwd.79 // next message creates a new one with the correct cwd.
63 if (serverSession && worktreePath !== activeWorktreePath && api) {80 if (serverSession && worktreePath !== activeWorktreePath && api) {
@@ -88,20 +105,24 @@ export default function BranchToolbar({ · 24 unmodified lines
88 currentWorktreePath: activeWorktreePath,105 currentWorktreePath: activeWorktreePath,
89 effectiveEnvMode,106 effectiveEnvMode,
90 });107 });
91 setDraftThreadContext(threadId, {108 setDraftThreadContext(draftId ?? threadRef, {
92 branch,109 branch,
93 worktreePath,110 worktreePath,
94 envMode: nextDraftEnvMode,111 envMode: nextDraftEnvMode,
112 projectRef: scopeProjectRef(environmentId, activeProject.id),
95 });113 });
96 },114 },
97 [115 [
98 activeThreadId,116 activeThreadId,
117 activeProject,
99 serverSession,118 serverSession,
100 activeWorktreePath,119 activeWorktreePath,
101 hasServerThread,120 hasServerThread,
102 setThreadBranchAction,121 setThreadBranchAction,
103 setDraftThreadContext,122 setDraftThreadContext,
104 threadId,123 draftId,
124 threadRef,
125 environmentId,
105 effectiveEnvMode,126 effectiveEnvMode,
106 ],127 ],
107 );128 );
@@ -156,6 +177,7 @@ export default function BranchToolbar({ · 48 unmodified lines
156 )}177 )}
157 178
158 <BranchToolbarBranchSelector179 <BranchToolbarBranchSelector
180 environmentId={environmentId}
159 activeProjectCwd={activeProject.cwd}181 activeProjectCwd={activeProject.cwd}
160 activeThreadBranch={activeThreadBranch}182 activeThreadBranch={activeThreadBranch}
161 activeWorktreePath={activeWorktreePath}183 activeWorktreePath={activeWorktreePath}
apps/web/src/components/BranchToolbarBranchSelector.tsx+13−10
@@ -1,4 +1,4 @@
1import type { GitBranch } from "@t3tools/contracts";1import type { EnvironmentId, GitBranch } from "@t3tools/contracts";
2import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";2import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3import { useVirtualizer } from "@tanstack/react-virtual";3import { useVirtualizer } from "@tanstack/react-virtual";
4import { ChevronDownIcon } from "lucide-react";4import { ChevronDownIcon } from "lucide-react";
@@ -16,7 +16,7 @@ import { · 11 unmodified lines
16 16
17import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery";17import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery";
18import { useGitStatus } from "../lib/gitStatusState";18import { useGitStatus } from "../lib/gitStatusState";
19import { readNativeApi } from "../nativeApi";19import { readEnvironmentApi } from "../environmentApi";
20import { parsePullRequestReference } from "../pullRequestReference";20import { parsePullRequestReference } from "../pullRequestReference";
21import {21import {
22 deriveLocalBranchNameFromRemoteRef,22 deriveLocalBranchNameFromRemoteRef,
@@ -39,6 +39,7 @@ import { · 16 unmodified lines
39import { toastManager } from "./ui/toast";39import { toastManager } from "./ui/toast";
40 40
41interface BranchToolbarBranchSelectorProps {41interface BranchToolbarBranchSelectorProps {
42 environmentId: EnvironmentId;
42 activeProjectCwd: string;43 activeProjectCwd: string;
43 activeThreadBranch: string | null;44 activeThreadBranch: string | null;
44 activeWorktreePath: string | null;45 activeWorktreePath: string | null;
@@ -70,6 +71,7 @@ function getBranchTriggerLabel(input: { · 25 unmodified lines
70}71}
71 72
72export function BranchToolbarBranchSelector({73export function BranchToolbarBranchSelector({
74 environmentId,
73 activeProjectCwd,75 activeProjectCwd,
74 activeThreadBranch,76 activeThreadBranch,
75 activeWorktreePath,77 activeWorktreePath,
@@ -85,16 +87,16 @@ export function BranchToolbarBranchSelector({ · 9 unmodified lines
85 const [branchQuery, setBranchQuery] = useState("");87 const [branchQuery, setBranchQuery] = useState("");
86 const deferredBranchQuery = useDeferredValue(branchQuery);88 const deferredBranchQuery = useDeferredValue(branchQuery);
87 89
88 const branchStatusQuery = useGitStatus(branchCwd);90 const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });
89 const trimmedBranchQuery = branchQuery.trim();91 const trimmedBranchQuery = branchQuery.trim();
90 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();92 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
91 93
92 useEffect(() => {94 useEffect(() => {
93 if (!branchCwd) return;95 if (!branchCwd) return;
94 void queryClient.prefetchInfiniteQuery(96 void queryClient.prefetchInfiniteQuery(
95 gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }),97 gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }),
96 );98 );
97 }, [branchCwd, queryClient]);99 }, [branchCwd, environmentId, queryClient]);
98 100
99 const {101 const {
100 data: branchesSearchData,102 data: branchesSearchData,
@@ -104,6 +106,7 @@ export function BranchToolbarBranchSelector({ · 3 unmodified lines
104 isPending: isBranchesSearchPending,106 isPending: isBranchesSearchPending,
105 } = useInfiniteQuery(107 } = useInfiniteQuery(
106 gitBranchSearchInfiniteQueryOptions({108 gitBranchSearchInfiniteQueryOptions({
109 environmentId,
107 cwd: branchCwd,110 cwd: branchCwd,
108 query: deferredTrimmedBranchQuery,111 query: deferredTrimmedBranchQuery,
109 }),112 }),
@@ -184,13 +187,13 @@ export function BranchToolbarBranchSelector({ · 74 unmodified lines
184 startBranchActionTransition(async () => {187 startBranchActionTransition(async () => {
185 await action().catch(() => undefined);188 await action().catch(() => undefined);
186 await queryClient189 await queryClient
187 .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) })190 .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) })
188 .catch(() => undefined);191 .catch(() => undefined);
189 });192 });
190 };193 };
191 194
192 const selectBranch = (branch: GitBranch) => {195 const selectBranch = (branch: GitBranch) => {
193 const api = readNativeApi();196 const api = readEnvironmentApi(environmentId);
194 if (!api || !branchCwd || isBranchActionPending) return;197 if (!api || !branchCwd || isBranchActionPending) return;
195 198
196 // In new-worktree mode, selecting a branch sets the base branch.199 // In new-worktree mode, selecting a branch sets the base branch.
@@ -248,7 +251,7 @@ export function BranchToolbarBranchSelector({ · 51 unmodified lines
248 251
249 const createBranch = (rawName: string) => {252 const createBranch = (rawName: string) => {
250 const name = rawName.trim();253 const name = rawName.trim();
251 const api = readNativeApi();254 const api = readEnvironmentApi(environmentId);
252 if (!api || !branchCwd || !name || isBranchActionPending) return;255 if (!api || !branchCwd || !name || isBranchActionPending) return;
253 256
254 setIsBranchMenuOpen(false);257 setIsBranchMenuOpen(false);
@@ -302,10 +305,10 @@ export function BranchToolbarBranchSelector({ · 47 unmodified lines
302 return;305 return;
303 }306 }
304 void queryClient.invalidateQueries({307 void queryClient.invalidateQueries({
305 queryKey: gitQueryKeys.branches(branchCwd),308 queryKey: gitQueryKeys.branches(environmentId, branchCwd),
306 });309 });
307 },310 },
308 [branchCwd, queryClient],311 [branchCwd, environmentId, queryClient],
309 );312 );
310 313
311 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);314 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
22import { LRUCache } from "../lib/lruCache";22import { LRUCache } from "../lib/lruCache";
23import { useTheme } from "../hooks/useTheme";23import { useTheme } from "../hooks/useTheme";
24import { resolveMarkdownFileLinkTarget } from "../markdown-links";24import { resolveMarkdownFileLinkTarget } from "../markdown-links";
25import { readNativeApi } from "../nativeApi";25import { readLocalApi } from "../localApi";
26 26
27class CodeHighlightErrorBoundary extends React.Component<27class CodeHighlightErrorBoundary extends React.Component<
28 { fallback: ReactNode; children: ReactNode },28 { fallback: ReactNode; children: ReactNode },
@@ -253,7 +253,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { · 224 unmodified lines
253 onClick={(event) => {253 onClick={(event) => {
254 event.preventDefault();254 event.preventDefault();
255 event.stopPropagation();255 event.stopPropagation();
256 const api = readNativeApi();256 const api = readLocalApi();
257 if (api) {257 if (api) {
258 void openInPreferredEditor(api, targetPath);258 void openInPreferredEditor(api, targetPath);
259 } else {259 } else {
apps/web/src/components/ChatView.browser.tsx+330−229
@@ -4,6 +4,7 @@ import "../index.css"; · 3 unmodified lines
4import {4import {
5 EventId,5 EventId,
6 ORCHESTRATION_WS_METHODS,6 ORCHESTRATION_WS_METHODS,
7 EnvironmentId,
7 type MessageId,8 type MessageId,
8 type OrchestrationEvent,9 type OrchestrationEvent,
9 type OrchestrationReadModel,10 type OrchestrationReadModel,
@@ -12,11 +13,16 @@ import { · 2 unmodified lines
12 type ServerLifecycleWelcomePayload,13 type ServerLifecycleWelcomePayload,
13 type ThreadId,14 type ThreadId,
14 type TurnId,15 type TurnId,
15 type UserInputQuestion,
16 WS_METHODS,16 WS_METHODS,
17 OrchestrationSessionStatus,17 OrchestrationSessionStatus,
18 DEFAULT_SERVER_SETTINGS,18 DEFAULT_SERVER_SETTINGS,
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import {
21 scopedProjectKey,
22 scopedThreadKey,
23 scopeProjectRef,
24 scopeThreadRef,
25} from "@t3tools/client-runtime";
20import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";26import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
21import { HttpResponse, http, ws } from "msw";27import { HttpResponse, http, ws } from "msw";
22import { setupWorker } from "msw/browser";28import { setupWorker } from "msw/browser";
@@ -24,19 +30,20 @@ import { page } from "vitest/browser"; · 1 unmodified line
24import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";30import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
25import { render } from "vitest-browser-react";31import { render } from "vitest-browser-react";
26 32
27import { useComposerDraftStore } from "../composerDraftStore";33import { useComposerDraftStore, DraftId } from "../composerDraftStore";
28import {34import {
29 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,35 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
30 type TerminalContextDraft,36 type TerminalContextDraft,
31 removeInlineTerminalContextPlaceholder,37 removeInlineTerminalContextPlaceholder,
32} from "../lib/terminalContext";38} from "../lib/terminalContext";
33import { isMacPlatform } from "../lib/utils";39import { isMacPlatform } from "../lib/utils";
34import { __resetNativeApiForTests } from "../nativeApi";40import { __resetLocalApiForTests } from "../localApi";
35import { AppAtomRegistryProvider } from "../rpc/atomRegistry";41import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
36import { getServerConfig } from "../rpc/serverState";42import { getServerConfig } from "../rpc/serverState";
37import { getRouter } from "../router";43import { getRouter } from "../router";
38import { useStore } from "../store";44import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
39import { useTerminalStateStore } from "../terminalStateStore";45import { useTerminalStateStore } from "../terminalStateStore";
46import { useUiStateStore } from "../uiStateStore";
40import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";47import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
41import { estimateTimelineMessageHeight } from "./timelineHeight";48import { estimateTimelineMessageHeight } from "./timelineHeight";
42import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";49import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
@@ -49,8 +56,13 @@ vi.mock("../lib/gitStatusState", () => ({ · 6 unmodified lines
49}));56}));
50 57
51const THREAD_ID = "thread-browser-test" as ThreadId;58const THREAD_ID = "thread-browser-test" as ThreadId;
52const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
53const PROJECT_ID = "project-1" as ProjectId;59const PROJECT_ID = "project-1" as ProjectId;
60const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
61const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);
62const THREAD_KEY = scopedThreadKey(THREAD_REF);
63const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
64const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`;
65const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID));
54const NOW_ISO = "2026-03-04T12:00:00.000Z";66const NOW_ISO = "2026-03-04T12:00:00.000Z";
55const BASE_TIME_MS = Date.parse(NOW_ISO);67const BASE_TIME_MS = Date.parse(NOW_ISO);
56const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>";68const 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
129 141
130function createBaseServerConfig(): ServerConfig {142function createBaseServerConfig(): ServerConfig {
131 return {143 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 },
132 cwd: "/repo/project",151 cwd: "/repo/project",
133 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",152 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
134 keybindings: [],153 keybindings: [],
@@ -314,6 +333,13 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { · 179 unmodified lines
314 snapshot,333 snapshot,
315 serverConfig: createBaseServerConfig(),334 serverConfig: createBaseServerConfig(),
316 welcome: {335 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 },
317 cwd: "/repo/project",343 cwd: "/repo/project",
318 projectName: "Project",344 projectName: "Project",
319 bootstrapProjectId: PROJECT_ID,345 bootstrapProjectId: PROJECT_ID,
@@ -396,6 +422,33 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest · 76 unmodified lines
396 };422 };
397}423}
398 424
425function 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
399function sendOrchestrationDomainEvent(event: OrchestrationEvent): void {452function sendOrchestrationDomainEvent(event: OrchestrationEvent): void {
400 rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event);453 rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event);
401}454}
@@ -419,25 +472,72 @@ async function waitForWsClient(): Promise<void> { · 17 unmodified lines
419 );472 );
420}473}
421 474
475function threadRefFor(threadId: ThreadId) {
476 return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);
477}
478
479function threadKeyFor(threadId: ThreadId): string {
480 return scopedThreadKey(threadRefFor(threadId));
481}
482
483function composerDraftFor(target: string) {
484 const { draftsByThreadKey } = useComposerDraftStore.getState();
485 return draftsByThreadKey[target] ?? draftsByThreadKey[threadKeyFor(target as ThreadId)];
486}
487
488function 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
497function 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
505function serverThreadPath(threadId: ThreadId): string {
506 return `/${LOCAL_ENVIRONMENT_ID}/${threadId}`;
507}
508
422async function waitForAppBootstrap(): Promise<void> {509async function waitForAppBootstrap(): Promise<void> {
423 await vi.waitFor(510 await vi.waitFor(
424 () => {511 () => {
425 expect(getServerConfig()).not.toBeNull();512 expect(getServerConfig()).not.toBeNull();
426 expect(useStore.getState().bootstrapComplete).toBe(true);513 expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true);
427 },514 },
428 { timeout: 8_000, interval: 16 },515 { timeout: 8_000, interval: 16 },
429 );516 );
430}517}
431 518
432async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {519async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
433 await waitForWsClient();520 await waitForWsClient();
434 fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId);521 fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId);
435 sendOrchestrationDomainEvent(522 sendOrchestrationDomainEvent(
436 createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence),523 createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence),
437 );524 );
525}
526
527async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
528 sendOrchestrationDomainEvent(
529 createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1),
530 );
531}
532
533async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
534 await materializePromotedDraftThreadViaDomainEvent(threadId);
535 await startPromotedServerThreadViaDomainEvent(threadId);
438 await vi.waitFor(536 await vi.waitFor(
439 () => {537 () => {
440 expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined();538 expect(useComposerDraftStore.getState().draftThreadsByThreadKey[threadKeyFor(threadId)]).toBe(
539 undefined,
540 );
441 },541 },
442 { timeout: 8_000, interval: 16 },542 { timeout: 8_000, interval: 16 },
443 );543 );
@@ -468,9 +568,12 @@ function withProjectScripts( · 24 unmodified lines
468 568
469function setDraftThreadWithoutWorktree(): void {569function setDraftThreadWithoutWorktree(): void {
470 useComposerDraftStore.setState({570 useComposerDraftStore.setState({
471 draftThreadsByThreadId: {571 draftThreadsByThreadKey: {
472 [THREAD_ID]: {572 [THREAD_KEY]: {
573 threadId: THREAD_ID,
574 environmentId: LOCAL_ENVIRONMENT_ID,
473 projectId: PROJECT_ID,575 projectId: PROJECT_ID,
576 logicalProjectKey: PROJECT_DRAFT_KEY,
474 createdAt: NOW_ISO,577 createdAt: NOW_ISO,
475 runtimeMode: "full-access",578 runtimeMode: "full-access",
476 interactionMode: "default",579 interactionMode: "default",
@@ -479,8 +582,8 @@ function setDraftThreadWithoutWorktree(): void { · 2 unmodified lines
479 envMode: "local",582 envMode: "local",
480 },583 },
481 },584 },
482 projectDraftThreadIdByProjectId: {585 logicalProjectDraftThreadKeyByLogicalProjectKey: {
483 [PROJECT_ID]: THREAD_ID,586 [PROJECT_DRAFT_KEY]: THREAD_KEY,
484 },587 },
485 });588 });
486}589}
@@ -542,51 +645,12 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { · 55 unmodified lines
542 };645 };
543}646}
544 647
545function createSnapshotWithPendingUserInput(options?: {648function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
546 questions?: ReadonlyArray<UserInputQuestion>;
547}): OrchestrationReadModel {
548 const snapshot = createSnapshotForTargetUser({649 const snapshot = createSnapshotForTargetUser({
549 targetMessageId: "msg-user-pending-input-target" as MessageId,650 targetMessageId: "msg-user-pending-input-target" as MessageId,
550 targetText: "question thread",651 targetText: "question thread",
551 });652 });
552 653
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
590 return {654 return {
591 ...snapshot,655 ...snapshot,
592 threads: snapshot.threads.map((thread) =>656 threads: snapshot.threads.map((thread) =>
@@ -601,7 +665,38 @@ function createSnapshotWithPendingUserInput(options?: { · 8 unmodified lines
601 summary: "User input requested",665 summary: "User input requested",
602 payload: {666 payload: {
603 requestId: "req-browser-user-input",667 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 ],
605 },700 },
606 turnId: null,701 turnId: null,
607 sequence: 1,702 sequence: 1,
@@ -1057,7 +1152,7 @@ async function mountChatView(options: { · 449 unmodified lines
1057 1152
1058 const router = getRouter(1153 const router = getRouter(
1059 createMemoryHistory({1154 createMemoryHistory({
1060 initialEntries: [`/${THREAD_ID}`],1155 initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`],
1061 }),1156 }),
1062 );1157 );
1063 1158
@@ -1162,42 +1257,32 @@ describe("ChatView timeline estimator parity (full app)", () => { · 98 unmodified lines
1162 return [];1257 return [];
1163 },1258 },
1164 });1259 });
1165 await __resetNativeApiForTests();1260 await __resetLocalApiForTests();
1166 await setViewport(DEFAULT_VIEWPORT);1261 await setViewport(DEFAULT_VIEWPORT);
1167 localStorage.clear();1262 localStorage.clear();
1168 document.body.innerHTML = "";1263 document.body.innerHTML = "";
1169 wsRequests.length = 0;1264 wsRequests.length = 0;
1170 customWsRpcResolver = null;1265 customWsRpcResolver = null;
1171 useComposerDraftStore.setState({1266 useComposerDraftStore.setState({
1172 draftsByThreadId: {},1267 draftsByThreadKey: {},
1173 draftThreadsByThreadId: {},1268 draftThreadsByThreadKey: {},
1174 projectDraftThreadIdByProjectId: {},1269 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
1175 stickyModelSelectionByProvider: {},1270 stickyModelSelectionByProvider: {},
1176 stickyActiveProvider: null,1271 stickyActiveProvider: null,
1177 });1272 });
1178 useStore.setState({1273 useStore.setState({
1179 projectIds: [],1274 activeEnvironmentId: null,
1180 projectById: {},1275 environmentStateById: {},
1181 threadIds: [],1276 });
1182 threadIdsByProjectId: {},1277 useUiStateStore.setState({
1183 threadShellById: {},1278 projectExpandedById: {},
1184 threadSessionById: {},1279 projectOrder: [],
1185 threadTurnStateById: {},1280 threadLastVisitedAtById: {},
1186 messageIdsByThreadId: {},
1187 messageByThreadId: {},
1188 activityIdsByThreadId: {},
1189 activityByThreadId: {},
1190 proposedPlanIdsByThreadId: {},
1191 proposedPlanByThreadId: {},
1192 turnDiffIdsByThreadId: {},
1193 turnDiffSummaryByThreadId: {},
1194 sidebarThreadSummaryById: {},
1195 bootstrapComplete: false,
1196 });1281 });
1197 useTerminalStateStore.persist.clearStorage();1282 useTerminalStateStore.persist.clearStorage();
1198 useTerminalStateStore.setState({1283 useTerminalStateStore.setState({
1199 terminalStateByThreadId: {},1284 terminalStateByThreadKey: {},
1200 terminalLaunchContextByThreadId: {},1285 terminalLaunchContextByThreadKey: {},
1201 terminalEventEntriesByKey: {},1286 terminalEventEntriesByKey: {},
1202 nextTerminalEventId: 1,1287 nextTerminalEventId: 1,
1203 });1288 });
@@ -1241,6 +1326,35 @@ describe("ChatView timeline estimator parity (full app)", () => { · 37 unmodified lines
1241 },1326 },
1242 );1327 );
1243 1328
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
1244 it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => {1358 it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => {
1245 const userText = "x".repeat(3_200);1359 const userText = "x".repeat(3_200);
1246 const targetMessageId = "msg-user-target-resize" as MessageId;1360 const targetMessageId = "msg-user-target-resize" as MessageId;
@@ -1438,8 +1552,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 191 unmodified lines
1438 }1552 }
1439 1553
1440 useTerminalStateStore.setState({1554 useTerminalStateStore.setState({
1441 terminalStateByThreadId: {1555 terminalStateByThreadKey: {
1442 [THREAD_ID]: {1556 [THREAD_KEY]: {
1443 terminalOpen: true,1557 terminalOpen: true,
1444 terminalHeight: 280,1558 terminalHeight: 280,
1445 terminalIds: ["default"],1559 terminalIds: ["default"],
@@ -1449,8 +1563,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 3 unmodified lines
1449 activeTerminalGroupId: "group-default",1563 activeTerminalGroupId: "group-default",
1450 },1564 },
1451 },1565 },
1452 terminalLaunchContextByThreadId: {1566 terminalLaunchContextByThreadKey: {
1453 [THREAD_ID]: {1567 [THREAD_KEY]: {
1454 cwd: "/repo/project",1568 cwd: "/repo/project",
1455 worktreePath: null,1569 worktreePath: null,
1456 },1570 },
@@ -1696,9 +1810,12 @@ describe("ChatView timeline estimator parity (full app)", () => { · 239 unmodified lines
1696 1810
1697 it("runs project scripts from local draft threads at the project cwd", async () => {1811 it("runs project scripts from local draft threads at the project cwd", async () => {
1698 useComposerDraftStore.setState({1812 useComposerDraftStore.setState({
1699 draftThreadsByThreadId: {1813 draftThreadsByThreadKey: {
1700 [THREAD_ID]: {1814 [THREAD_KEY]: {
1815 threadId: THREAD_ID,
1816 environmentId: LOCAL_ENVIRONMENT_ID,
1701 projectId: PROJECT_ID,1817 projectId: PROJECT_ID,
1818 logicalProjectKey: PROJECT_DRAFT_KEY,
1702 createdAt: NOW_ISO,1819 createdAt: NOW_ISO,
1703 runtimeMode: "full-access",1820 runtimeMode: "full-access",
1704 interactionMode: "default",1821 interactionMode: "default",
@@ -1707,8 +1824,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines
1707 envMode: "local",1824 envMode: "local",
1708 },1825 },
1709 },1826 },
1710 projectDraftThreadIdByProjectId: {1827 logicalProjectDraftThreadKeyByLogicalProjectKey: {
1711 [PROJECT_ID]: THREAD_ID,1828 [PROJECT_DRAFT_KEY]: THREAD_KEY,
1712 },1829 },
1713 });1830 });
1714 1831
@@ -1772,9 +1889,12 @@ describe("ChatView timeline estimator parity (full app)", () => { · 57 unmodified lines
1772 1889
1773 it("runs project scripts from worktree draft threads at the worktree cwd", async () => {1890 it("runs project scripts from worktree draft threads at the worktree cwd", async () => {
1774 useComposerDraftStore.setState({1891 useComposerDraftStore.setState({
1775 draftThreadsByThreadId: {1892 draftThreadsByThreadKey: {
1776 [THREAD_ID]: {1893 [THREAD_KEY]: {
1894 threadId: THREAD_ID,
1895 environmentId: LOCAL_ENVIRONMENT_ID,
1777 projectId: PROJECT_ID,1896 projectId: PROJECT_ID,
1897 logicalProjectKey: PROJECT_DRAFT_KEY,
1778 createdAt: NOW_ISO,1898 createdAt: NOW_ISO,
1779 runtimeMode: "full-access",1899 runtimeMode: "full-access",
1780 interactionMode: "default",1900 interactionMode: "default",
@@ -1783,8 +1903,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines
1783 envMode: "worktree",1903 envMode: "worktree",
1784 },1904 },
1785 },1905 },
1786 projectDraftThreadIdByProjectId: {1906 logicalProjectDraftThreadKeyByLogicalProjectKey: {
1787 [PROJECT_ID]: THREAD_ID,1907 [PROJECT_DRAFT_KEY]: THREAD_KEY,
1788 },1908 },
1789 });1909 });
1790 1910
@@ -1835,9 +1955,12 @@ describe("ChatView timeline estimator parity (full app)", () => { · 44 unmodified lines
1835 1955
1836 it("lets the server own setup after preparing a pull request worktree thread", async () => {1956 it("lets the server own setup after preparing a pull request worktree thread", async () => {
1837 useComposerDraftStore.setState({1957 useComposerDraftStore.setState({
1838 draftThreadsByThreadId: {1958 draftThreadsByThreadKey: {
1839 [THREAD_ID]: {1959 [THREAD_KEY]: {
1960 threadId: THREAD_ID,
1961 environmentId: LOCAL_ENVIRONMENT_ID,
1840 projectId: PROJECT_ID,1962 projectId: PROJECT_ID,
1963 logicalProjectKey: PROJECT_DRAFT_KEY,
1841 createdAt: NOW_ISO,1964 createdAt: NOW_ISO,
1842 runtimeMode: "full-access",1965 runtimeMode: "full-access",
1843 interactionMode: "default",1966 interactionMode: "default",
@@ -1846,8 +1969,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines
1846 envMode: "local",1969 envMode: "local",
1847 },1970 },
1848 },1971 },
1849 projectDraftThreadIdByProjectId: {1972 logicalProjectDraftThreadKeyByLogicalProjectKey: {
1850 [PROJECT_ID]: THREAD_ID,1973 [PROJECT_DRAFT_KEY]: THREAD_KEY,
1851 },1974 },
1852 });1975 });
1853 1976
@@ -1957,12 +2080,15 @@ describe("ChatView timeline estimator parity (full app)", () => { · 103 unmodified lines
1957 2080
1958 it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => {2081 it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => {
1959 useTerminalStateStore.setState({2082 useTerminalStateStore.setState({
1960 terminalStateByThreadId: {},2083 terminalStateByThreadKey: {},
1961 });2084 });
1962 useComposerDraftStore.setState({2085 useComposerDraftStore.setState({
1963 draftThreadsByThreadId: {2086 draftThreadsByThreadKey: {
1964 [THREAD_ID]: {2087 [THREAD_KEY]: {
2088 threadId: THREAD_ID,
2089 environmentId: LOCAL_ENVIRONMENT_ID,
1965 projectId: PROJECT_ID,2090 projectId: PROJECT_ID,
2091 logicalProjectKey: PROJECT_DRAFT_KEY,
1966 createdAt: NOW_ISO,2092 createdAt: NOW_ISO,
1967 runtimeMode: "full-access",2093 runtimeMode: "full-access",
1968 interactionMode: "default",2094 interactionMode: "default",
@@ -1971,8 +2097,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines
1971 envMode: "worktree",2097 envMode: "worktree",
1972 },2098 },
1973 },2099 },
1974 projectDraftThreadIdByProjectId: {2100 logicalProjectDraftThreadKeyByLogicalProjectKey: {
1975 [PROJECT_ID]: THREAD_ID,2101 [PROJECT_DRAFT_KEY]: THREAD_KEY,
1976 },2102 },
1977 });2103 });
1978 2104
@@ -1998,7 +2124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 19 unmodified lines
1998 });2124 });
1999 2125
2000 try {2126 try {
2001 useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it");2127 useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
2002 await waitForLayout();2128 await waitForLayout();
2003 2129
2004 const sendButton = await waitForSendButton();2130 const sendButton = await waitForSendButton();
@@ -2057,12 +2183,15 @@ describe("ChatView timeline estimator parity (full app)", () => { · 52 unmodified lines
2057 2183
2058 it("shows the send state once bootstrap dispatch is in flight", async () => {2184 it("shows the send state once bootstrap dispatch is in flight", async () => {
2059 useTerminalStateStore.setState({2185 useTerminalStateStore.setState({
2060 terminalStateByThreadId: {},2186 terminalStateByThreadKey: {},
2061 });2187 });
2062 useComposerDraftStore.setState({2188 useComposerDraftStore.setState({
2063 draftThreadsByThreadId: {2189 draftThreadsByThreadKey: {
2064 [THREAD_ID]: {2190 [THREAD_KEY]: {
2191 threadId: THREAD_ID,
2192 environmentId: LOCAL_ENVIRONMENT_ID,
2065 projectId: PROJECT_ID,2193 projectId: PROJECT_ID,
2194 logicalProjectKey: PROJECT_DRAFT_KEY,
2066 createdAt: NOW_ISO,2195 createdAt: NOW_ISO,
2067 runtimeMode: "full-access",2196 runtimeMode: "full-access",
2068 interactionMode: "default",2197 interactionMode: "default",
@@ -2071,8 +2200,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines
2071 envMode: "worktree",2200 envMode: "worktree",
2072 },2201 },
2073 },2202 },
2074 projectDraftThreadIdByProjectId: {2203 logicalProjectDraftThreadKeyByLogicalProjectKey: {
2075 [PROJECT_ID]: THREAD_ID,2204 [PROJECT_DRAFT_KEY]: THREAD_KEY,
2076 },2205 },
2077 });2206 });
2078 2207
@@ -2101,7 +2230,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 22 unmodified lines
2101 });2230 });
2102 2231
2103 try {2232 try {
2104 useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it");2233 useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
2105 await waitForLayout();2234 await waitForLayout();
2106 2235
2107 const sendButton = await waitForSendButton();2236 const sendButton = await waitForSendButton();
@@ -2193,7 +2322,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 85 unmodified lines
2193 const removedLabel = "Terminal 1 lines 1-2";2322 const removedLabel = "Terminal 1 lines 1-2";
2194 const addedLabel = "Terminal 2 lines 9-10";2323 const addedLabel = "Terminal 2 lines 9-10";
2195 useComposerDraftStore.getState().addTerminalContext(2324 useComposerDraftStore.getState().addTerminalContext(
2196 THREAD_ID,2325 THREAD_REF,
2197 createTerminalContext({2326 createTerminalContext({
2198 id: "ctx-removed",2327 id: "ctx-removed",
2199 terminalLabel: "Terminal 1",2328 terminalLabel: "Terminal 1",
@@ -2220,21 +2349,21 @@ describe("ChatView timeline estimator parity (full app)", () => { · 20 unmodified lines
2220 );2349 );
2221 2350
2222 const store = useComposerDraftStore.getState();2351 const store = useComposerDraftStore.getState();
2223 const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? "";2352 const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? "";
2224 const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0);2353 const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0);
2225 store.setPrompt(THREAD_ID, nextPrompt.prompt);2354 store.setPrompt(THREAD_REF, nextPrompt.prompt);
2226 store.removeTerminalContext(THREAD_ID, "ctx-removed");2355 store.removeTerminalContext(THREAD_REF, "ctx-removed");
2227 2356
2228 await vi.waitFor(2357 await vi.waitFor(
2229 () => {2358 () => {
2230 expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined();2359 expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined();
2231 expect(document.body.textContent).not.toContain(removedLabel);2360 expect(document.body.textContent).not.toContain(removedLabel);
2232 },2361 },
2233 { timeout: 8_000, interval: 16 },2362 { timeout: 8_000, interval: 16 },
2234 );2363 );
2235 2364
2236 useComposerDraftStore.getState().addTerminalContext(2365 useComposerDraftStore.getState().addTerminalContext(
2237 THREAD_ID,2366 THREAD_REF,
2238 createTerminalContext({2367 createTerminalContext({
2239 id: "ctx-added",2368 id: "ctx-added",
2240 terminalLabel: "Terminal 2",2369 terminalLabel: "Terminal 2",
@@ -2246,7 +2375,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 5 unmodified lines
2246 2375
2247 await vi.waitFor(2376 await vi.waitFor(
2248 () => {2377 () => {
2249 const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID];2378 const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY];
2250 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]);2379 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]);
2251 expect(document.body.textContent).toContain(addedLabel);2380 expect(document.body.textContent).toContain(addedLabel);
2252 expect(document.body.textContent).not.toContain(removedLabel);2381 expect(document.body.textContent).not.toContain(removedLabel);
@@ -2261,7 +2390,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 8 unmodified lines
2261 it("disables send when the composer only contains an expired terminal pill", async () => {2390 it("disables send when the composer only contains an expired terminal pill", async () => {
2262 const expiredLabel = "Terminal 1 line 4";2391 const expiredLabel = "Terminal 1 line 4";
2263 useComposerDraftStore.getState().addTerminalContext(2392 useComposerDraftStore.getState().addTerminalContext(
2264 THREAD_ID,2393 THREAD_REF,
2265 createTerminalContext({2394 createTerminalContext({
2266 id: "ctx-expired-only",2395 id: "ctx-expired-only",
2267 terminalLabel: "Terminal 1",2396 terminalLabel: "Terminal 1",
@@ -2297,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 29 unmodified lines
2297 it("warns when sending text while omitting expired terminal pills", async () => {2426 it("warns when sending text while omitting expired terminal pills", async () => {
2298 const expiredLabel = "Terminal 1 line 4";2427 const expiredLabel = "Terminal 1 line 4";
2299 useComposerDraftStore.getState().addTerminalContext(2428 useComposerDraftStore.getState().addTerminalContext(
2300 THREAD_ID,2429 THREAD_REF,
2301 createTerminalContext({2430 createTerminalContext({
2302 id: "ctx-expired-send-warning",2431 id: "ctx-expired-send-warning",
2303 terminalLabel: "Terminal 1",2432 terminalLabel: "Terminal 1",
@@ -2308,7 +2437,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 4 unmodified lines
2308 );2437 );
2309 useComposerDraftStore2438 useComposerDraftStore
2310 .getState()2439 .getState()
2311 .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`);2440 .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`);
2312 2441
2313 const mounted = await mountChatView({2442 const mounted = await mountChatView({
2314 viewport: DEFAULT_VIEWPORT,2443 viewport: DEFAULT_VIEWPORT,
@@ -2448,7 +2577,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 133 unmodified lines
2448 }2577 }
2449 });2578 });
2450 2579
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 () => {
2452 const mounted = await mountChatView({2581 const mounted = await mountChatView({
2453 viewport: DEFAULT_VIEWPORT,2582 viewport: DEFAULT_VIEWPORT,
2454 snapshot: createSnapshotForTargetUser({2583 snapshot: createSnapshotForTargetUser({
@@ -2470,21 +2599,34 @@ describe("ChatView timeline estimator parity (full app)", () => { · 15 unmodified lines
2470 (path) => UUID_ROUTE_RE.test(path),2599 (path) => UUID_ROUTE_RE.test(path),
2471 "Route should have changed to a new draft thread UUID.",2600 "Route should have changed to a new draft thread UUID.",
2472 );2601 );
2473 const newThreadId = newThreadPath.slice(1) as ThreadId;2602 const newDraftId = draftIdFromPath(newThreadPath);
2603 const newThreadId = draftThreadIdFor(newDraftId);
2474 2604
2475 // The composer editor should be present for the new draft thread.2605 // The composer editor should be present for the new draft thread.
2476 await waitForComposerEditor();2606 await waitForComposerEditor();
2477 2607
2478 // Simulate the steady-state promotion path: the server emits2608 // `thread.created` should only mark the draft as promoting; it should
2479 // `thread.created`, the client materializes the thread incrementally,2609 // not navigate away until the server thread has actual runtime state.
2480 // and the draft is cleared by live batch effects.2610 await materializePromotedDraftThreadViaDomainEvent(newThreadId);
2481 await promoteDraftThreadViaDomainEvent(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 );
2482 2624
2483 // The route should still be on the new thread — not redirected away.2625 // The route should switch to the canonical server thread path.
2484 await waitForURL(2626 await waitForURL(
2485 mounted.router,2627 mounted.router,
2486 (path) => path === newThreadPath,2628 (path) => path === serverThreadPath(newThreadId),
2487 "New thread should remain selected after server thread promotion clears the draft.",2629 "Promoted drafts should canonicalize to the server thread route.",
2488 );2630 );
2489 2631
2490 // The empty thread view and composer should still be visible.2632 // The empty thread view and composer should still be visible.
@@ -2497,6 +2639,48 @@ describe("ChatView timeline estimator parity (full app)", () => { · 6 unmodified lines
2497 }2639 }
2498 });2640 });
2499 2641
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
2500 it("snapshots sticky codex settings into a new draft thread", async () => {2684 it("snapshots sticky codex settings into a new draft thread", async () => {
2501 useComposerDraftStore.setState({2685 useComposerDraftStore.setState({
2502 stickyModelSelectionByProvider: {2686 stickyModelSelectionByProvider: {
@@ -2531,9 +2715,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 28 unmodified lines
2531 (path) => UUID_ROUTE_RE.test(path),2715 (path) => UUID_ROUTE_RE.test(path),
2532 "Route should have changed to a new draft thread UUID.",2716 "Route should have changed to a new draft thread UUID.",
2533 );2717 );
2534 const newThreadId = newThreadPath.slice(1) as ThreadId;2718 const newDraftId = draftIdFromPath(newThreadPath);
2535 2719
2536 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({2720 expect(composerDraftFor(newDraftId)).toMatchObject({
2537 modelSelectionByProvider: {2721 modelSelectionByProvider: {
2538 codex: {2722 codex: {
2539 provider: "codex",2723 provider: "codex",
@@ -2584,9 +2768,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 44 unmodified lines
2584 (path) => UUID_ROUTE_RE.test(path),2768 (path) => UUID_ROUTE_RE.test(path),
2585 "Route should have changed to a new sticky claude draft thread UUID.",2769 "Route should have changed to a new sticky claude draft thread UUID.",
2586 );2770 );
2587 const newThreadId = newThreadPath.slice(1) as ThreadId;2771 const newDraftId = draftIdFromPath(newThreadPath);
2588 2772
2589 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({2773 expect(composerDraftFor(newDraftId)).toMatchObject({
2590 modelSelectionByProvider: {2774 modelSelectionByProvider: {
2591 claudeAgent: {2775 claudeAgent: {
2592 provider: "claudeAgent",2776 provider: "claudeAgent",
@@ -2624,9 +2808,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 31 unmodified lines
2624 (path) => UUID_ROUTE_RE.test(path),2808 (path) => UUID_ROUTE_RE.test(path),
2625 "Route should have changed to a new draft thread UUID.",2809 "Route should have changed to a new draft thread UUID.",
2626 );2810 );
2627 const newThreadId = newThreadPath.slice(1) as ThreadId;2811 const newDraftId = draftIdFromPath(newThreadPath);
2628 2812
2629 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined();2813 expect(composerDraftFor(newDraftId)).toBe(undefined);
2630 } finally {2814 } finally {
2631 await mounted.cleanup();2815 await mounted.cleanup();
2632 }2816 }
@@ -2666,9 +2850,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 33 unmodified lines
2666 (path) => UUID_ROUTE_RE.test(path),2850 (path) => UUID_ROUTE_RE.test(path),
2667 "Route should have changed to a sticky draft thread UUID.",2851 "Route should have changed to a sticky draft thread UUID.",
2668 );2852 );
2669 const threadId = threadPath.slice(1) as ThreadId;2853 const draftId = draftIdFromPath(threadPath);
2670 2854
2671 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({2855 expect(composerDraftFor(draftId)).toMatchObject({
2672 modelSelectionByProvider: {2856 modelSelectionByProvider: {
2673 codex: {2857 codex: {
2674 provider: "codex",2858 provider: "codex",
@@ -2681,7 +2865,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 6 unmodified lines
2681 activeProvider: "codex",2865 activeProvider: "codex",
2682 });2866 });
2683 2867
2684 useComposerDraftStore.getState().setModelSelection(threadId, {2868 useComposerDraftStore.getState().setModelSelection(draftId, {
2685 provider: "codex",2869 provider: "codex",
2686 model: "gpt-5.4",2870 model: "gpt-5.4",
2687 options: {2871 options: {
@@ -2697,7 +2881,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 9 unmodified lines
2697 (path) => path === threadPath,2881 (path) => path === threadPath,
2698 "New-thread should reuse the existing project draft thread.",2882 "New-thread should reuse the existing project draft thread.",
2699 );2883 );
2700 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({2884 expect(composerDraftFor(draftId)).toMatchObject({
2701 modelSelectionByProvider: {2885 modelSelectionByProvider: {
2702 codex: {2886 codex: {
2703 provider: "codex",2887 provider: "codex",
@@ -2804,7 +2988,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 100 unmodified lines
2804 (path) => UUID_ROUTE_RE.test(path),2988 (path) => UUID_ROUTE_RE.test(path),
2805 "Route should have changed to a promoted draft thread UUID.",2989 "Route should have changed to a promoted draft thread UUID.",
2806 );2990 );
2807 const promotedThreadId = promotedThreadPath.slice(1) as ThreadId;2991 const promotedDraftId = draftIdFromPath(promotedThreadPath);
2992 const promotedThreadId = draftThreadIdFor(promotedDraftId);
2808 2993
2809 await promoteDraftThreadViaDomainEvent(promotedThreadId);2994 await promoteDraftThreadViaDomainEvent(promotedThreadId);
2810 2995
@@ -2925,70 +3110,10 @@ describe("ChatView timeline estimator parity (full app)", () => { · 114 unmodified lines
2925 }3110 }
2926 });3111 });
2927 3112
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 () => {
2929 const mounted = await mountChatView({3114 const mounted = await mountChatView({
2930 viewport: WIDE_FOOTER_VIEWPORT,3115 viewport: DEFAULT_VIEWPORT,
2931 snapshot: createSnapshotWithPendingUserInput(),3116 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 }),
2992 resolveRpc: (body) => {3117 resolveRpc: (body) => {
2993 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {3118 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
2994 return {3119 return {
@@ -3000,37 +3125,11 @@ describe("ChatView timeline estimator parity (full app)", () => { · 5 unmodified lines
3000 });3125 });
3001 3126
3002 try {3127 try {
3003 const serverOption = await waitForButtonContainingText("Server");3128 const firstOption = await waitForButtonContainingText("Tight");
3004 serverOption.click();3129 firstOption.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();
3030 3130
3031 const submitButton = await waitForButtonByText("Submit answers");3131 const finalOption = await waitForButtonContainingText("Conservative");
3032 expect(submitButton.disabled).toBe(false);3132 finalOption.click();
3033 submitButton.click();
3034 3133
3035 await vi.waitFor(3134 await vi.waitFor(
3036 () => {3135 () => {
@@ -3042,6 +3141,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 5 unmodified lines
3042 | {3141 | {
3043 _tag: string;3142 _tag: string;
3044 type?: string;3143 type?: string;
3144 requestId?: string;
3045 answers?: Record<string, unknown>;3145 answers?: Record<string, unknown>;
3046 }3146 }
3047 | undefined;3147 | undefined;
@@ -3049,9 +3149,10 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1 unmodified line
3049 expect(dispatchRequest).toMatchObject({3149 expect(dispatchRequest).toMatchObject({
3050 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,3150 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
3051 type: "thread.user-input.respond",3151 type: "thread.user-input.respond",
3152 requestId: "req-browser-user-input",
3052 answers: {3153 answers: {
3053 scope: ["Server", "Web"],3154 scope: "Tight",
3054 risk: "Balanced",3155 risk: "Conservative",
3055 },3156 },
3056 });3157 });
3057 },3158 },
apps/web/src/components/ChatView.logic.test.ts+57−7
@@ -1,6 +1,7 @@
1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";1import { scopeThreadRef } from "@t3tools/client-runtime";
2import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vitest";3import { afterEach, describe, expect, it, vi } from "vitest";
3import { useStore } from "../store";4import { type EnvironmentState, useStore } from "../store";
4import { type Thread } from "../types";5import { type Thread } from "../types";
5 6
6import {7import {
@@ -10,9 +11,12 @@ import { · 3 unmodified lines
10 deriveComposerSendState,11 deriveComposerSendState,
11 hasServerAcknowledgedLocalDispatch,12 hasServerAcknowledgedLocalDispatch,
12 reconcileMountedTerminalThreadIds,13 reconcileMountedTerminalThreadIds,
14 shouldWriteThreadErrorToCurrentServerThread,
13 waitForStartedServerThread,15 waitForStartedServerThread,
14} from "./ChatView.logic";16} from "./ChatView.logic";
15 17
18const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
19
16describe("deriveComposerSendState", () => {20describe("deriveComposerSendState", () => {
17 it("treats expired terminal pills as non-sendable content", () => {21 it("treats expired terminal pills as non-sendable content", () => {
18 const state = deriveComposerSendState({22 const state = deriveComposerSendState({
@@ -170,6 +174,36 @@ describe("reconcileMountedTerminalThreadIds", () => { · 151 unmodified lines
170 });174 });
171});175});
172 176
177describe("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
173const makeThread = (input?: {207const makeThread = (input?: {
174 id?: ThreadId;208 id?: ThreadId;
175 latestTurn?: {209 latestTurn?: {
@@ -181,6 +215,7 @@ const makeThread = (input?: { · 5 unmodified lines
181 } | null;215 } | null;
182}): Thread => ({216}): Thread => ({
183 id: input?.id ?? ThreadId.makeUnsafe("thread-1"),217 id: input?.id ?? ThreadId.makeUnsafe("thread-1"),
218 environmentId: localEnvironmentId,
184 codexThreadId: null,219 codexThreadId: null,
185 projectId: ProjectId.makeUnsafe("project-1"),220 projectId: ProjectId.makeUnsafe("project-1"),
186 title: "Thread",221 title: "Thread",
@@ -208,11 +243,12 @@ const makeThread = (input?: { · 21 unmodified lines
208 243
209function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) {244function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) {
210 const projectId = ProjectId.makeUnsafe("project-1");245 const projectId = ProjectId.makeUnsafe("project-1");
211 useStore.setState({246 const environmentState: EnvironmentState = {
212 projectIds: [projectId],247 projectIds: [projectId],
213 projectById: {248 projectById: {
214 [projectId]: {249 [projectId]: {
215 id: projectId,250 id: projectId,
251 environmentId: localEnvironmentId,
216 name: "Project",252 name: "Project",
217 cwd: "/tmp/project",253 cwd: "/tmp/project",
218 defaultModelSelection: {254 defaultModelSelection: {
@@ -233,6 +269,7 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) · 14 unmodified lines
233 thread.id,269 thread.id,
234 {270 {
235 id: thread.id,271 id: thread.id,
272 environmentId: thread.environmentId,
236 codexThreadId: thread.codexThreadId,273 codexThreadId: thread.codexThreadId,
237 projectId: thread.projectId,274 projectId: thread.projectId,
238 title: thread.title,275 title: thread.title,
@@ -301,6 +338,12 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) · 62 unmodified lines
301 ),338 ),
302 sidebarThreadSummaryById: {},339 sidebarThreadSummaryById: {},
303 bootstrapComplete: true,340 bootstrapComplete: true,
341 };
342 useStore.setState({
343 activeEnvironmentId: localEnvironmentId,
344 environmentStateById: {
345 [localEnvironmentId]: environmentState,
346 },
304 });347 });
305}348}
306 349
@@ -326,14 +369,16 @@ describe("waitForStartedServerThread", () => { · 19 unmodified lines
326 }),369 }),
327 ]);370 ]);
328 371
329 await expect(waitForStartedServerThread(threadId)).resolves.toBe(true);372 await expect(
373 waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)),
374 ).resolves.toBe(true);
330 });375 });
331 376
332 it("waits for the thread to start via subscription updates", async () => {377 it("waits for the thread to start via subscription updates", async () => {
333 const threadId = ThreadId.makeUnsafe("thread-wait");378 const threadId = ThreadId.makeUnsafe("thread-wait");
334 setStoreThreads([makeThread({ id: threadId })]);379 setStoreThreads([makeThread({ id: threadId })]);
335 380
336 const promise = waitForStartedServerThread(threadId, 500);381 const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500);
337 382
338 setStoreThreads([383 setStoreThreads([
339 makeThread({384 makeThread({
@@ -376,7 +421,9 @@ describe("waitForStartedServerThread", () => { · 36 unmodified lines
376 return originalSubscribe(listener);421 return originalSubscribe(listener);
377 });422 });
378 423
379 await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true);424 await expect(
425 waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500),
426 ).resolves.toBe(true);
380 });427 });
381 428
382 it("returns false after the timeout when the thread never starts", async () => {429 it("returns false after the timeout when the thread never starts", async () => {
@@ -384,7 +431,7 @@ describe("waitForStartedServerThread", () => { · 1 unmodified line
384 431
385 const threadId = ThreadId.makeUnsafe("thread-timeout");432 const threadId = ThreadId.makeUnsafe("thread-timeout");
386 setStoreThreads([makeThread({ id: threadId })]);433 setStoreThreads([makeThread({ id: threadId })]);
387 const promise = waitForStartedServerThread(threadId, 500);434 const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500);
388 435
389 await vi.advanceTimersByTimeAsync(500);436 await vi.advanceTimersByTimeAsync(500);
390 437
@@ -414,6 +461,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 23 unmodified lines
414 it("does not clear local dispatch before server state changes", () => {461 it("does not clear local dispatch before server state changes", () => {
415 const localDispatch = createLocalDispatchSnapshot({462 const localDispatch = createLocalDispatchSnapshot({
416 id: ThreadId.makeUnsafe("thread-1"),463 id: ThreadId.makeUnsafe("thread-1"),
464 environmentId: localEnvironmentId,
417 codexThreadId: null,465 codexThreadId: null,
418 projectId,466 projectId,
419 title: "Thread",467 title: "Thread",
@@ -450,6 +498,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 30 unmodified lines
450 it("clears local dispatch when a new turn is already settled", () => {498 it("clears local dispatch when a new turn is already settled", () => {
451 const localDispatch = createLocalDispatchSnapshot({499 const localDispatch = createLocalDispatchSnapshot({
452 id: ThreadId.makeUnsafe("thread-1"),500 id: ThreadId.makeUnsafe("thread-1"),
501 environmentId: localEnvironmentId,
453 codexThreadId: null,502 codexThreadId: null,
454 projectId,503 projectId,
455 title: "Thread",504 title: "Thread",
@@ -495,6 +544,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 39 unmodified lines
495 it("clears local dispatch when the session changes without an observed running phase", () => {544 it("clears local dispatch when the session changes without an observed running phase", () => {
496 const localDispatch = createLocalDispatchSnapshot({545 const localDispatch = createLocalDispatchSnapshot({
497 id: ThreadId.makeUnsafe("thread-1"),546 id: ThreadId.makeUnsafe("thread-1"),
547 environmentId: localEnvironmentId,
498 codexThreadId: null,548 codexThreadId: null,
499 projectId,549 projectId,
500 title: "Thread",550 title: "Thread",
apps/web/src/components/ChatView.logic.ts+36−9
@@ -1,9 +1,16 @@
1import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts";1import {
2 type EnvironmentId,
3 ProjectId,
4 type ModelSelection,
5 type ScopedThreadRef,
6 type ThreadId,
7 type TurnId,
8} from "@t3tools/contracts";
2import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";9import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";
3import { randomUUID } from "~/lib/utils";10import { randomUUID } from "~/lib/utils";
4import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";11import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
5import { Schema } from "effect";12import { Schema } from "effect";
6import { selectThreadById, useStore } from "../store";13import { selectThreadByRef, useStore } from "../store";
7import {14import {
8 filterTerminalContextsWithText,15 filterTerminalContextsWithText,
9 stripInlineTerminalContextPlaceholders,16 stripInlineTerminalContextPlaceholders,
@@ -24,6 +31,7 @@ export function buildLocalDraftThread( · 14 unmodified lines
24): Thread {31): Thread {
25 return {32 return {
26 id: threadId,33 id: threadId,
34 environmentId: draftThread.environmentId,
27 codexThreadId: null,35 codexThreadId: null,
28 projectId: draftThread.projectId,36 projectId: draftThread.projectId,
29 title: "New thread",37 title: "New thread",
@@ -44,13 +52,32 @@ export function buildLocalDraftThread( · 14 unmodified lines
44 };52 };
45}53}
46 54
55export 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
47export function reconcileMountedTerminalThreadIds(input: {74export function reconcileMountedTerminalThreadIds(input: {
48 currentThreadIds: ReadonlyArray<ThreadId>;75 currentThreadIds: ReadonlyArray<string>;
49 openThreadIds: ReadonlyArray<ThreadId>;76 openThreadIds: ReadonlyArray<string>;
50 activeThreadId: ThreadId | null;77 activeThreadId: string | null;
51 activeThreadTerminalOpen: boolean;78 activeThreadTerminalOpen: boolean;
52 maxHiddenThreadCount?: number;79 maxHiddenThreadCount?: number;
53}): ThreadId[] {80}): string[] {
54 const openThreadIdSet = new Set(input.openThreadIds);81 const openThreadIdSet = new Set(input.openThreadIds);
55 const hiddenThreadIds = input.currentThreadIds.filter(82 const hiddenThreadIds = input.currentThreadIds.filter(
56 (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId),83 (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId),
@@ -199,10 +226,10 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { · 142 unmodified lines
199}226}
200 227
201export async function waitForStartedServerThread(228export async function waitForStartedServerThread(
202 threadId: ThreadId,229 threadRef: ScopedThreadRef,
203 timeoutMs = 1_000,230 timeoutMs = 1_000,
204): Promise<boolean> {231): Promise<boolean> {
205 const getThread = () => selectThreadById(threadId)(useStore.getState());232 const getThread = () => selectThreadByRef(useStore.getState(), threadRef);
206 const thread = getThread();233 const thread = getThread();
207 234
208 if (threadHasStarted(thread)) {235 if (threadHasStarted(thread)) {
@@ -225,7 +252,7 @@ export async function waitForStartedServerThread( · 16 unmodified lines
225 };252 };
226 253
227 const unsubscribe = useStore.subscribe((state) => {254 const unsubscribe = useStore.subscribe((state) => {
228 if (!threadHasStarted(selectThreadById(threadId)(state))) {255 if (!threadHasStarted(selectThreadByRef(state, threadRef))) {
229 return;256 return;
230 }257 }
231 finish(true);258 finish(true);
apps/web/src/components/ChatView.tsx+522−254
@@ -2,6 +2,7 @@ import { · 1 unmodified line
2 type ApprovalRequestId,2 type ApprovalRequestId,
3 DEFAULT_MODEL_BY_PROVIDER,3 DEFAULT_MODEL_BY_PROVIDER,
4 type ClaudeCodeEffort,4 type ClaudeCodeEffort,
5 type EnvironmentId,
5 type MessageId,6 type MessageId,
6 type ModelSelection,7 type ModelSelection,
7 type ProjectScript,8 type ProjectScript,
@@ -12,6 +13,7 @@ import { · 4 unmodified lines
12 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,13 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
13 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,14 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
14 type ServerProvider,15 type ServerProvider,
16 type ScopedThreadRef,
15 type ThreadId,17 type ThreadId,
16 type TurnId,18 type TurnId,
17 type KeybindingCommand,19 type KeybindingCommand,
@@ -20,6 +22,12 @@ import { · 2 unmodified lines
20 RuntimeMode,22 RuntimeMode,
21 TerminalOpenInput,23 TerminalOpenInput,
22} from "@t3tools/contracts";24} from "@t3tools/contracts";
25import {
26 parseScopedThreadKey,
27 scopedThreadKey,
28 scopeProjectRef,
29 scopeThreadRef,
30} from "@t3tools/client-runtime";
23import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";31import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
24import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";32import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
25import { truncate } from "@t3tools/shared/String";33import { truncate } from "@t3tools/shared/String";
@@ -27,9 +35,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr · 1 unmodified line
27import { useQuery } from "@tanstack/react-query";35import { useQuery } from "@tanstack/react-query";
28import { useDebouncedValue } from "@tanstack/react-pacer";36import { useDebouncedValue } from "@tanstack/react-pacer";
29import { useNavigate, useSearch } from "@tanstack/react-router";37import { useNavigate, useSearch } from "@tanstack/react-router";
38import { useShallow } from "zustand/react/shallow";
30import { useGitStatus } from "~/lib/gitStatusState";39import { useGitStatus } from "~/lib/gitStatusState";
31import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";40import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
41import { readEnvironmentApi } from "../environmentApi";
32import { isElectron } from "../env";42import { isElectron } from "../env";
43import { readLocalApi } from "../localApi";
33import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";44import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
34import {45import {
35 clampCollapsedComposerCursor,46 clampCollapsedComposerCursor,
@@ -64,8 +75,8 @@ import { · 28 unmodified lines
64 togglePendingUserInputOptionSelection,75 togglePendingUserInputOptionSelection,
65 type PendingUserInputDraftAnswer,76 type PendingUserInputDraftAnswer,
66} from "../pendingUserInput";77} from "../pendingUserInput";
67import { useStore } from "../store";78import { selectThreadsAcrossEnvironments, useStore } from "../store";
68import { createThreadSelector } from "../storeSelectors";79import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
69import { useUiStateStore } from "../uiStateStore";80import { useUiStateStore } from "../uiStateStore";
70import {81import {
71 buildPlanImplementationThreadTitle,82 buildPlanImplementationThreadTitle,
@@ -114,8 +125,7 @@ import { · 42 unmodified lines
114 projectScriptIdFromCommand,125 projectScriptIdFromCommand,
115} from "~/projectScripts";126} from "~/projectScripts";
116import { SidebarTrigger } from "./ui/sidebar";127import { SidebarTrigger } from "./ui/sidebar";
117import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";128import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";
118import { readNativeApi } from "~/nativeApi";
119import {129import {
120 getProviderModelCapabilities,130 getProviderModelCapabilities,
121 getProviderModels,131 getProviderModels,
@@ -124,6 +134,8 @@ import { · 2 unmodified lines
124import { useSettings } from "../hooks/useSettings";134import { useSettings } from "../hooks/useSettings";
125import { resolveAppModelSelection } from "../modelSelection";135import { resolveAppModelSelection } from "../modelSelection";
126import { isTerminalFocused } from "../lib/terminalFocus";136import { isTerminalFocused } from "../lib/terminalFocus";
137import { deriveLogicalProjectKey } from "../logicalProject";
138import { buildDraftThreadRouteParams } from "../threadRoutes";
127import {139import {
128 type ComposerImageAttachment,140 type ComposerImageAttachment,
129 type DraftThreadEnvMode,141 type DraftThreadEnvMode,
@@ -131,6 +143,7 @@ import { · 1 unmodified line
131 useComposerDraftStore,143 useComposerDraftStore,
132 useEffectiveComposerModelState,144 useEffectiveComposerModelState,
133 useComposerThreadDraft,145 useComposerThreadDraft,
146 type DraftId,
134} from "../composerDraftStore";147} from "../composerDraftStore";
135import {148import {
136 appendTerminalContextsToPrompt,149 appendTerminalContextsToPrompt,
@@ -187,6 +200,7 @@ import { · 50 unmodified lines
187 reconcileMountedTerminalThreadIds,200 reconcileMountedTerminalThreadIds,
188 revokeBlobPreviewUrl,201 revokeBlobPreviewUrl,
189 revokeUserMessagePreviewUrls,202 revokeUserMessagePreviewUrls,
203 shouldWriteThreadErrorToCurrentServerThread,
190 threadHasStarted,204 threadHasStarted,
191 waitForStartedServerThread,205 waitForStartedServerThread,
192} from "./ChatView.logic";206} from "./ChatView.logic";
@@ -203,6 +217,7 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES · 10 unmodified lines
203const IMAGE_ONLY_BOOTSTRAP_PROMPT =217const IMAGE_ONLY_BOOTSTRAP_PROMPT =
204 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";218 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
205const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];219const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
220const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
206const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];221const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
207const EMPTY_PROVIDERS: ServerProvider[] = [];222const EMPTY_PROVIDERS: ServerProvider[] = [];
208const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};223const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
@@ -210,26 +225,115 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw · 1 unmodified line
210type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;225type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
211 226
212function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] {227function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] {
213 const threadShellById = useStore((state) => state.threadShellById);228 return useStore(
214 const proposedPlanIdsByThreadId = useStore((state) => state.proposedPlanIdsByThreadId);229 useMemo(() => {
215 const proposedPlanByThreadId = useStore((state) => state.proposedPlanByThreadId);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 }
216 275
217 return useMemo(276 if (!shell) {
218 () =>277 const previous = previousEntries.get(threadId);
219 threadIds.flatMap((threadId) => {278 if (
220 if (!threadShellById[threadId]) {279 previous &&
221 return [];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);
222 }325 }
223 326
224 const proposedPlans =327 if (!changed && previousResult.length === nextResult.length) {
225 proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => {328 return previousResult;
226 const plan = proposedPlanByThreadId[threadId]?.[planId];329 }
227 return plan ? [plan] : [];
228 }) ?? [];
229 330
230 return [{ id: threadId, proposedPlans }];331 previousThreadIds = threadIds;
231 }),332 previousEntries = nextEntries;
232 [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById],333 previousResult = nextResult;
334 return nextResult;
335 };
336 }, [threadIds]),
233 );337 );
234}338}
235 339
@@ -278,9 +382,19 @@ const terminalContextIdListsEqual = ( · 42 unmodified lines
278): boolean =>382): boolean =>
279 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);383 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
280 384
281interface ChatViewProps {385type ChatViewProps =
282 threadId: ThreadId;386 | {
283}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 };
284 398
285interface TerminalLaunchContext {399interface TerminalLaunchContext {
286 threadId: ThreadId;400 threadId: ThreadId;
@@ -358,6 +472,7 @@ function useLocalDispatchState(input: { · 71 unmodified lines
358}472}
359 473
360interface PersistentThreadTerminalDrawerProps {474interface PersistentThreadTerminalDrawerProps {
475 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
361 threadId: ThreadId;476 threadId: ThreadId;
362 visible: boolean;477 visible: boolean;
363 launchContext: PersistentTerminalLaunchContext | null;478 launchContext: PersistentTerminalLaunchContext | null;
@@ -369,6 +484,7 @@ interface PersistentThreadTerminalDrawerProps { · 5 unmodified lines
369}484}
370 485
371function PersistentThreadTerminalDrawer({486function PersistentThreadTerminalDrawer({
487 threadRef,
372 threadId,488 threadId,
373 visible,489 visible,
374 launchContext,490 launchContext,
@@ -378,19 +494,16 @@ function PersistentThreadTerminalDrawer({ · 3 unmodified lines
378 closeShortcutLabel,494 closeShortcutLabel,
379 onAddTerminalContext,495 onAddTerminalContext,
380}: PersistentThreadTerminalDrawerProps) {496}: PersistentThreadTerminalDrawerProps) {
381 const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId]));497 const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
382 const draftThread = useComposerDraftStore(498 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
383 (store) => store.draftThreadsByThreadId[threadId] ?? null,499 const projectRef = serverThread
384 );500 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
385 const project = useStore((state) =>501 : draftThread
386 serverThread?.projectId502 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
387 ? state.projectById[serverThread.projectId]503 : null;
388 : draftThread?.projectId504 const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef]));
389 ? state.projectById[draftThread.projectId]
390 : undefined,
391 );
392 const terminalState = useTerminalStateStore((state) =>505 const terminalState = useTerminalStateStore((state) =>
393 selectThreadTerminalState(state.terminalStateByThreadId, threadId),506 selectThreadTerminalState(state.terminalStateByThreadKey, threadRef),
394 );507 );
395 const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight);508 const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight);
396 const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal);509 const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal);
@@ -436,32 +549,32 @@ function PersistentThreadTerminalDrawer({ · 39 unmodified lines
436 549
437 const setTerminalHeight = useCallback(550 const setTerminalHeight = useCallback(
438 (height: number) => {551 (height: number) => {
439 storeSetTerminalHeight(threadId, height);552 storeSetTerminalHeight(threadRef, height);
440 },553 },
441 [storeSetTerminalHeight, threadId],554 [storeSetTerminalHeight, threadRef],
442 );555 );
443 556
444 const splitTerminal = useCallback(() => {557 const splitTerminal = useCallback(() => {
445 storeSplitTerminal(threadId, `terminal-${randomUUID()}`);558 storeSplitTerminal(threadRef, `terminal-${randomUUID()}`);
446 bumpFocusRequestId();559 bumpFocusRequestId();
447 }, [bumpFocusRequestId, storeSplitTerminal, threadId]);560 }, [bumpFocusRequestId, storeSplitTerminal, threadRef]);
448 561
449 const createNewTerminal = useCallback(() => {562 const createNewTerminal = useCallback(() => {
450 storeNewTerminal(threadId, `terminal-${randomUUID()}`);563 storeNewTerminal(threadRef, `terminal-${randomUUID()}`);
451 bumpFocusRequestId();564 bumpFocusRequestId();
452 }, [bumpFocusRequestId, storeNewTerminal, threadId]);565 }, [bumpFocusRequestId, storeNewTerminal, threadRef]);
453 566
454 const activateTerminal = useCallback(567 const activateTerminal = useCallback(
455 (terminalId: string) => {568 (terminalId: string) => {
456 storeSetActiveTerminal(threadId, terminalId);569 storeSetActiveTerminal(threadRef, terminalId);
457 bumpFocusRequestId();570 bumpFocusRequestId();
458 },571 },
459 [bumpFocusRequestId, storeSetActiveTerminal, threadId],572 [bumpFocusRequestId, storeSetActiveTerminal, threadRef],
460 );573 );
461 574
462 const closeTerminal = useCallback(575 const closeTerminal = useCallback(
463 (terminalId: string) => {576 (terminalId: string) => {
464 const api = readNativeApi();577 const api = readEnvironmentApi(threadRef.environmentId);
465 if (!api) return;578 if (!api) return;
466 const isFinalTerminal = terminalState.terminalIds.length <= 1;579 const isFinalTerminal = terminalState.terminalIds.length <= 1;
467 const fallbackExitWrite = () =>580 const fallbackExitWrite = () =>
@@ -482,10 +595,10 @@ function PersistentThreadTerminalDrawer({ · 14 unmodified lines
482 void fallbackExitWrite();595 void fallbackExitWrite();
483 }596 }
484 597
485 storeCloseTerminal(threadId, terminalId);598 storeCloseTerminal(threadRef, terminalId);
486 bumpFocusRequestId();599 bumpFocusRequestId();
487 },600 },
488 [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId],601 [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef],
489 );602 );
490 603
491 const handleAddTerminalContext = useCallback(604 const handleAddTerminalContext = useCallback(
@@ -505,6 +618,7 @@ function PersistentThreadTerminalDrawer({ · 13 unmodified lines
505 return (618 return (
506 <div className={visible ? undefined : "hidden"}>619 <div className={visible ? undefined : "hidden"}>
507 <ThreadTerminalDrawer620 <ThreadTerminalDrawer
621 threadRef={threadRef}
508 threadId={threadId}622 threadId={threadId}
509 cwd={cwd}623 cwd={cwd}
510 worktreePath={effectiveWorktreePath}624 worktreePath={effectiveWorktreePath}
@@ -530,12 +644,27 @@ function PersistentThreadTerminalDrawer({ · 19 unmodified lines
530 );644 );
531}645}
532 646
533export default function ChatView({ threadId }: ChatViewProps) {647export default function ChatView(props: ChatViewProps) {
534 const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId]));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 );
535 const setStoreThreadError = useStore((store) => store.setError);662 const setStoreThreadError = useStore((store) => store.setError);
536 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);663 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
537 const activeThreadLastVisitedAt = useUiStateStore(664 const activeThreadLastVisitedAt = useUiStateStore((store) =>
538 (store) => store.threadLastVisitedAtById[threadId],665 routeKind === "server"
666 ? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))]
667 : undefined,
539 );668 );
540 const settings = useSettings();669 const settings = useSettings();
541 const setStickyComposerModelSelection = useComposerDraftStore(670 const setStickyComposerModelSelection = useComposerDraftStore(
@@ -548,7 +677,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines
548 select: (params) => parseDiffRouteSearch(params),677 select: (params) => parseDiffRouteSearch(params),
549 });678 });
550 const { resolvedTheme } = useTheme();679 const { resolvedTheme } = useTheme();
551 const composerDraft = useComposerThreadDraft(threadId);680 const composerDraft = useComposerThreadDraft(composerDraftTarget);
552 const prompt = composerDraft.prompt;681 const prompt = composerDraft.prompt;
553 const composerImages = composerDraft.images;682 const composerImages = composerDraft.images;
554 const composerTerminalContexts = composerDraft.terminalContexts;683 const composerTerminalContexts = composerDraft.terminalContexts;
@@ -591,16 +720,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 36 unmodified lines
591 );720 );
592 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);721 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
593 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);722 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
594 const getDraftThreadByProjectId = useComposerDraftStore(723 const getDraftSessionByLogicalProjectKey = useComposerDraftStore(
595 (store) => store.getDraftThreadByProjectId,724 (store) => store.getDraftSessionByLogicalProjectKey,
596 );725 );
597 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);726 const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
598 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);727 const getDraftSession = useComposerDraftStore((store) => store.getDraftSession);
599 const clearProjectDraftThreadId = useComposerDraftStore(728 const setLogicalProjectDraftThreadId = useComposerDraftStore(
600 (store) => store.clearProjectDraftThreadId,729 (store) => store.setLogicalProjectDraftThreadId,
601 );730 );
602 const draftThread = useComposerDraftStore(731 const draftThread = useComposerDraftStore((store) =>
603 (store) => store.draftThreadsByThreadId[threadId] ?? null,732 routeKind === "server"
733 ? store.getDraftSessionByRef(routeThreadRef)
734 : draftId
735 ? store.getDraftSession(draftId)
736 : null,
604 );737 );
605 const promptRef = useRef(prompt);738 const promptRef = useRef(prompt);
606 const [showScrollToBottom, setShowScrollToBottom] = useState(false);739 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
@@ -610,8 +743,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 3 unmodified lines
610 const optimisticUserMessagesRef = useRef(optimisticUserMessages);743 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
611 optimisticUserMessagesRef.current = optimisticUserMessages;744 optimisticUserMessagesRef.current = optimisticUserMessages;
612 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts);745 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts);
613 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<746 const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<
614 Record<ThreadId, string | null>747 Record<string, string | null>
615 >({});748 >({});
616 const [isConnecting, _setIsConnecting] = useState(false);749 const [isConnecting, _setIsConnecting] = useState(false);
617 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);750 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
@@ -689,66 +822,73 @@ export default function ChatView({ threadId }: ChatViewProps) { · 71 unmodified lines
689 setMessagesScrollElement(element);822 setMessagesScrollElement(element);
690 }, []);823 }, []);
691 824
692 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);825 const terminalState = useTerminalStateStore((state) =>
693 const terminalState = useMemo(826 selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef),
694 () => selectThreadTerminalState(terminalStateByThreadId, threadId),
695 [terminalStateByThreadId, threadId],
696 );827 );
697 const openTerminalThreadIds = useMemo(828 const openTerminalThreadKeys = useTerminalStateStore(
698 () =>829 useShallow((state) =>
699 Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) =>830 Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) =>
700 nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [],831 nextTerminalState.terminalOpen ? [nextThreadKey] : [],
701 ),832 ),
702 [terminalStateByThreadId],833 ),
703 );834 );
704 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);835 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
705 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);836 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
706 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);837 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);
707 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);838 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);
708 const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);839 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 );
710 const storeServerTerminalLaunchContext = useTerminalStateStore(847 const storeServerTerminalLaunchContext = useTerminalStateStore(
711 (s) => s.terminalLaunchContextByThreadId[threadId] ?? null,848 (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null,
712 );849 );
713 const storeClearTerminalLaunchContext = useTerminalStateStore(850 const storeClearTerminalLaunchContext = useTerminalStateStore(
714 (s) => s.clearTerminalLaunchContext,851 (s) => s.clearTerminalLaunchContext,
715 );852 );
716 const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId);853 const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
717 const draftThreadIds = useMemo(854 const draftThreadKeys = useMemo(
718 () => Object.keys(draftThreadsByThreadId) as ThreadId[],855 () =>
719 [draftThreadsByThreadId],856 Object.values(draftThreadsByThreadKey).map((draftThread) =>
857 scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)),
858 ),
859 [draftThreadsByThreadKey],
720 );860 );
721 const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState<ThreadId[]>([]);861 const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState<string[]>([]);
722 862
723 const setPrompt = useCallback(863 const setPrompt = useCallback(
724 (nextPrompt: string) => {864 (nextPrompt: string) => {
725 setComposerDraftPrompt(threadId, nextPrompt);865 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
726 },866 },
727 [setComposerDraftPrompt, threadId],867 [composerDraftTarget, setComposerDraftPrompt],
728 );868 );
729 const addComposerImage = useCallback(869 const addComposerImage = useCallback(
730 (image: ComposerImageAttachment) => {870 (image: ComposerImageAttachment) => {
731 addComposerDraftImage(threadId, image);871 addComposerDraftImage(composerDraftTarget, image);
732 },872 },
733 [addComposerDraftImage, threadId],873 [addComposerDraftImage, composerDraftTarget],
734 );874 );
735 const addComposerImagesToDraft = useCallback(875 const addComposerImagesToDraft = useCallback(
736 (images: ComposerImageAttachment[]) => {876 (images: ComposerImageAttachment[]) => {
737 addComposerDraftImages(threadId, images);877 addComposerDraftImages(composerDraftTarget, images);
738 },878 },
739 [addComposerDraftImages, threadId],879 [addComposerDraftImages, composerDraftTarget],
740 );880 );
741 const addComposerTerminalContextsToDraft = useCallback(881 const addComposerTerminalContextsToDraft = useCallback(
742 (contexts: TerminalContextDraft[]) => {882 (contexts: TerminalContextDraft[]) => {
743 addComposerDraftTerminalContexts(threadId, contexts);883 addComposerDraftTerminalContexts(composerDraftTarget, contexts);
744 },884 },
745 [addComposerDraftTerminalContexts, threadId],885 [addComposerDraftTerminalContexts, composerDraftTarget],
746 );886 );
747 const removeComposerImageFromDraft = useCallback(887 const removeComposerImageFromDraft = useCallback(
748 (imageId: string) => {888 (imageId: string) => {
749 removeComposerDraftImage(threadId, imageId);889 removeComposerDraftImage(composerDraftTarget, imageId);
750 },890 },
751 [removeComposerDraftImage, threadId],891 [composerDraftTarget, removeComposerDraftImage],
752 );892 );
753 const removeComposerTerminalContextFromDraft = useCallback(893 const removeComposerTerminalContextFromDraft = useCallback(
754 (contextId: string) => {894 (contextId: string) => {
@@ -761,7 +901,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines
761 const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex);901 const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex);
762 promptRef.current = nextPrompt.prompt;902 promptRef.current = nextPrompt.prompt;
763 setPrompt(nextPrompt.prompt);903 setPrompt(nextPrompt.prompt);
764 removeComposerDraftTerminalContext(threadId, contextId);904 removeComposerDraftTerminalContext(composerDraftTarget, contextId);
765 setComposerCursor(nextPrompt.cursor);905 setComposerCursor(nextPrompt.cursor);
766 setComposerTrigger(906 setComposerTrigger(
767 detectComposerTrigger(907 detectComposerTrigger(
@@ -770,13 +910,19 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines
770 ),910 ),
771 );911 );
772 },912 },
773 [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId],913 [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt],
774 );914 );
775 915
776 const fallbackDraftProject = useStore((state) =>916 const fallbackDraftProjectRef = draftThread
777 draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined,917 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
918 : null;
919 const fallbackDraftProject = useStore(
920 useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]),
778 );921 );
779 const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);922 const localDraftError =
923 routeKind === "server" && serverThread
924 ? null
925 : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null);
780 const localDraftThread = useMemo(926 const localDraftThread = useMemo(
781 () =>927 () =>
782 draftThread928 draftThread
@@ -792,20 +938,25 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines
792 : undefined,938 : undefined,
793 [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId],939 [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId],
794 );940 );
795 const activeThread = serverThread ?? localDraftThread;941 const isServerThread = routeKind === "server" && serverThread !== undefined;
942 const activeThread = isServerThread ? serverThread : localDraftThread;
796 const runtimeMode =943 const runtimeMode =
797 composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;944 composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
798 const interactionMode =945 const interactionMode =
799 composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;946 composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
800 const isServerThread = serverThread !== undefined;
801 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;947 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
802 const canCheckoutPullRequestIntoThread = isLocalDraftThread;948 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
803 const diffOpen = rawSearch.diff === "1";949 const diffOpen = rawSearch.diff === "1";
804 const activeThreadId = activeThread?.id ?? null;950 const activeThreadId = activeThread?.id ?? null;
805 const existingOpenTerminalThreadIds = useMemo(() => {951 const activeThreadRef = useMemo(
806 const existingThreadIds = new Set<ThreadId>([...serverThreadIds, ...draftThreadIds]);952 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
807 return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId));953 [activeThread],
808 }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]);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]);
809 const activeLatestTurn = activeThread?.latestTurn ?? null;960 const activeLatestTurn = activeThread?.latestTurn ?? null;
810 const threadPlanCatalog = useThreadPlanCatalog(961 const threadPlanCatalog = useThreadPlanCatalog(
811 useMemo(() => {962 useMemo(() => {
@@ -825,12 +976,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 13 unmodified lines
825 [activeThread?.activities],976 [activeThread?.activities],
826 );977 );
827 useEffect(() => {978 useEffect(() => {
828 setMountedTerminalThreadIds((currentThreadIds) => {979 setMountedTerminalThreadKeys((currentThreadIds) => {
829 const nextThreadIds = reconcileMountedTerminalThreadIds({980 const nextThreadIds = reconcileMountedTerminalThreadIds({
830 currentThreadIds,981 currentThreadIds,
831 openThreadIds: existingOpenTerminalThreadIds,982 openThreadIds: existingOpenTerminalThreadKeys,
832 activeThreadId,983 activeThreadId: activeThreadKey,
833 activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen),984 activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen),
834 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,985 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
835 });986 });
836 return currentThreadIds.length === nextThreadIds.length &&987 return currentThreadIds.length === nextThreadIds.length &&
@@ -838,10 +989,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line
838 ? currentThreadIds989 ? currentThreadIds
839 : nextThreadIds;990 : nextThreadIds;
840 });991 });
841 }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]);992 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]);
842 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);993 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
843 const activeProject = useStore((state) =>994 const activeProjectRef = activeThread
844 activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined,995 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
996 : null;
997 const activeProject = useStore(
998 useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]),
845 );999 );
846 1000
847 const openPullRequestDialog = useCallback(1001 const openPullRequestDialog = useCallback(
@@ -867,50 +1021,71 @@ export default function ChatView({ threadId }: ChatViewProps) { · 19 unmodified lines
867 if (!activeProject) {1021 if (!activeProject) {
868 throw new Error("No active project is available for this pull request.");1022 throw new Error("No active project is available for this pull request.");
869 }1023 }
870 const storedDraftThread = getDraftThreadByProjectId(activeProject.id);1024 const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id);
871 if (storedDraftThread) {1025 const logicalProjectKey = deriveLogicalProjectKey(activeProject);
872 setDraftThreadContext(storedDraftThread.threadId, input);1026 const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey);
873 setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input);1027 if (storedDraftSession) {
874 if (storedDraftThread.threadId !== threadId) {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) {
875 await navigate({1039 await navigate({
876 to: "/$threadId",1040 to: "/draft/$draftId",
877 params: { threadId: storedDraftThread.threadId },1041 params: buildDraftThreadRouteParams(storedDraftSession.draftId),
878 });1042 });
879 }1043 }
880 return storedDraftThread.threadId;1044 return storedDraftSession.threadId;
881 }1045 }
882 1046
883 const activeDraftThread = getDraftThread(threadId);1047 const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null;
884 if (!isServerThread && activeDraftThread?.projectId === activeProject.id) {1048 if (
885 setDraftThreadContext(threadId, input);1049 !isServerThread &&
886 setProjectDraftThreadId(activeProject.id, threadId, input);1050 activeDraftSession?.logicalProjectKey === logicalProjectKey &&
887 return threadId;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;
888 }1062 }
889 1063
890 clearProjectDraftThreadId(activeProject.id);1064 const nextDraftId = newDraftId();
891 const nextThreadId = newThreadId();1065 const nextThreadId = newThreadId();
892 setProjectDraftThreadId(activeProject.id, nextThreadId, {1066 setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {
1067 threadId: nextThreadId,
893 createdAt: new Date().toISOString(),1068 createdAt: new Date().toISOString(),
894 runtimeMode: DEFAULT_RUNTIME_MODE,1069 runtimeMode: DEFAULT_RUNTIME_MODE,
895 interactionMode: DEFAULT_INTERACTION_MODE,1070 interactionMode: DEFAULT_INTERACTION_MODE,
896 ...input,1071 ...input,
897 });1072 });
898 await navigate({1073 await navigate({
899 to: "/$threadId",1074 to: "/draft/$draftId",
900 params: { threadId: nextThreadId },1075 params: buildDraftThreadRouteParams(nextDraftId),
901 });1076 });
902 return nextThreadId;1077 return nextThreadId;
903 },1078 },
904 [1079 [
905 activeProject,1080 activeProject,
906 clearProjectDraftThreadId,1081 draftId,
907 getDraftThread,1082 getDraftSession,
908 getDraftThreadByProjectId,1083 getDraftSessionByLogicalProjectKey,
909 isServerThread,1084 isServerThread,
910 navigate,1085 navigate,
1086 routeKind,
911 setDraftThreadContext,1087 setDraftThreadContext,
912 setProjectDraftThreadId,1088 setLogicalProjectDraftThreadId,
913 threadId,
914 ],1089 ],
915 );1090 );
916 1091
@@ -934,12 +1109,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 17 unmodified lines
934 const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;1109 const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
935 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;1110 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
936 1111
937 markThreadVisited(serverThread.id);1112 markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)));
938 }, [1113 }, [
939 activeLatestTurn?.completedAt,1114 activeLatestTurn?.completedAt,
940 activeThreadLastVisitedAt,1115 activeThreadLastVisitedAt,
941 latestTurnSettled,1116 latestTurnSettled,
942 markThreadVisited,1117 markThreadVisited,
1118 serverThread?.environmentId,
943 serverThread?.id,1119 serverThread?.id,
944 ]);1120 ]);
945 1121
@@ -959,7 +1135,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 13 unmodified lines
959 );1135 );
960 const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider;1136 const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider;
961 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({1137 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
962 threadId,1138 threadRef: composerDraftTarget,
963 providers: providerStatuses,1139 providers: providerStatuses,
964 selectedProvider,1140 selectedProvider,
965 threadModelSelection: activeThread?.modelSelection,1141 threadModelSelection: activeThread?.modelSelection,
@@ -1358,7 +1534,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 392 unmodified lines
1358 (debouncerState) => ({ isPending: debouncerState.isPending }),1534 (debouncerState) => ({ isPending: debouncerState.isPending }),
1359 );1535 );
1360 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";1536 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
1361 const gitStatusQuery = useGitStatus(gitCwd);1537 const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd });
1362 const keybindings = useServerKeybindings();1538 const keybindings = useServerKeybindings();
1363 const availableEditors = useServerAvailableEditors();1539 const availableEditors = useServerAvailableEditors();
1364 const modelOptionsByProvider = useMemo(1540 const modelOptionsByProvider = useMemo(
@@ -1394,6 +1570,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 29 unmodified lines
1394 );1570 );
1395 const workspaceEntriesQuery = useQuery(1571 const workspaceEntriesQuery = useQuery(
1396 projectSearchEntriesQueryOptions({1572 projectSearchEntriesQueryOptions({
1573 environmentId,
1397 cwd: gitCwd,1574 cwd: gitCwd,
1398 query: effectivePathQuery,1575 query: effectivePathQuery,
1399 enabled: isPathTrigger,1576 enabled: isPathTrigger,
@@ -1531,16 +1708,22 @@ export default function ChatView({ threadId }: ChatViewProps) { · 131 unmodified lines
1531 [keybindings, nonTerminalShortcutLabelOptions],1708 [keybindings, nonTerminalShortcutLabelOptions],
1532 );1709 );
1533 const onToggleDiff = useCallback(() => {1710 const onToggleDiff = useCallback(() => {
1711 if (!isServerThread) {
1712 return;
1713 }
1534 void navigate({1714 void navigate({
1535 to: "/$threadId",1715 to: "/$environmentId/$threadId",
1536 params: { threadId },1716 params: {
1717 environmentId,
1718 threadId,
1719 },
1537 replace: true,1720 replace: true,
1538 search: (previous) => {1721 search: (previous) => {
1539 const rest = stripDiffSearchParams(previous);1722 const rest = stripDiffSearchParams(previous);
1540 return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };1723 return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
1541 },1724 },
1542 });1725 });
1543 }, [diffOpen, navigate, threadId]);1726 }, [diffOpen, environmentId, isServerThread, navigate, threadId]);
1544 1727
1545 const envLocked = Boolean(1728 const envLocked = Boolean(
1546 activeThread &&1729 activeThread &&
@@ -1561,21 +1744,27 @@ export default function ChatView({ threadId }: ChatViewProps) { · 14 unmodified lines
1561 (targetThreadId: ThreadId | null, error: string | null) => {1744 (targetThreadId: ThreadId | null, error: string | null) => {
1562 if (!targetThreadId) return;1745 if (!targetThreadId) return;
1563 const nextError = sanitizeThreadErrorMessage(error);1746 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) {
1565 setStoreThreadError(targetThreadId, nextError);1753 setStoreThreadError(targetThreadId, nextError);
1566 return;1754 return;
1567 }1755 }
1568 setLocalDraftErrorsByThreadId((existing) => {1756 const localDraftErrorKey = draftId ?? targetThreadId;
1569 if ((existing[targetThreadId] ?? null) === nextError) {1757 setLocalDraftErrorsByDraftId((existing) => {
1758 if ((existing[localDraftErrorKey] ?? null) === nextError) {
1570 return existing;1759 return existing;
1571 }1760 }
1572 return {1761 return {
1573 ...existing,1762 ...existing,
1574 [targetThreadId]: nextError,1763 [localDraftErrorKey]: nextError,
1575 };1764 };
1576 });1765 });
1577 },1766 },
1578 [setStoreThreadError],1767 [draftId, routeThreadRef, serverThread, setStoreThreadError],
1579 );1768 );
1580 1769
1581 const focusComposer = useCallback(() => {1770 const focusComposer = useCallback(() => {
@@ -1606,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 24 unmodified lines
1606 insertion.cursor,1795 insertion.cursor,
1607 );1796 );
1608 const inserted = insertComposerDraftTerminalContext(1797 const inserted = insertComposerDraftTerminalContext(
1609 activeThread.id,1798 scopeThreadRef(activeThread.environmentId, activeThread.id),
1610 insertion.prompt,1799 insertion.prompt,
1611 {1800 {
1612 id: randomUUID(),1801 id: randomUUID(),
@@ -1630,30 +1819,30 @@ export default function ChatView({ threadId }: ChatViewProps) { · 17 unmodified lines
1630 );1819 );
1631 const setTerminalOpen = useCallback(1820 const setTerminalOpen = useCallback(
1632 (open: boolean) => {1821 (open: boolean) => {
1633 if (!activeThreadId) return;1822 if (!activeThreadRef) return;
1634 storeSetTerminalOpen(activeThreadId, open);1823 storeSetTerminalOpen(activeThreadRef, open);
1635 },1824 },
1636 [activeThreadId, storeSetTerminalOpen],1825 [activeThreadRef, storeSetTerminalOpen],
1637 );1826 );
1638 const toggleTerminalVisibility = useCallback(() => {1827 const toggleTerminalVisibility = useCallback(() => {
1639 if (!activeThreadId) return;1828 if (!activeThreadRef) return;
1640 setTerminalOpen(!terminalState.terminalOpen);1829 setTerminalOpen(!terminalState.terminalOpen);
1641 }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]);1830 }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]);
1642 const splitTerminal = useCallback(() => {1831 const splitTerminal = useCallback(() => {
1643 if (!activeThreadId || hasReachedSplitLimit) return;1832 if (!activeThreadRef || hasReachedSplitLimit) return;
1644 const terminalId = `terminal-${randomUUID()}`;1833 const terminalId = `terminal-${randomUUID()}`;
1645 storeSplitTerminal(activeThreadId, terminalId);1834 storeSplitTerminal(activeThreadRef, terminalId);
1646 setTerminalFocusRequestId((value) => value + 1);1835 setTerminalFocusRequestId((value) => value + 1);
1647 }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]);1836 }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]);
1648 const createNewTerminal = useCallback(() => {1837 const createNewTerminal = useCallback(() => {
1649 if (!activeThreadId) return;1838 if (!activeThreadRef) return;
1650 const terminalId = `terminal-${randomUUID()}`;1839 const terminalId = `terminal-${randomUUID()}`;
1651 storeNewTerminal(activeThreadId, terminalId);1840 storeNewTerminal(activeThreadRef, terminalId);
1652 setTerminalFocusRequestId((value) => value + 1);1841 setTerminalFocusRequestId((value) => value + 1);
1653 }, [activeThreadId, storeNewTerminal]);1842 }, [activeThreadRef, storeNewTerminal]);
1654 const closeTerminal = useCallback(1843 const closeTerminal = useCallback(
1655 (terminalId: string) => {1844 (terminalId: string) => {
1656 const api = readNativeApi();1845 const api = readEnvironmentApi(environmentId);
1657 if (!activeThreadId || !api) return;1846 if (!activeThreadId || !api) return;
1658 const isFinalTerminal = terminalState.terminalIds.length <= 1;1847 const isFinalTerminal = terminalState.terminalIds.length <= 1;
1659 const fallbackExitWrite = () =>1848 const fallbackExitWrite = () =>
@@ -1676,10 +1865,18 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines
1676 } else {1865 } else {
1677 void fallbackExitWrite();1866 void fallbackExitWrite();
1678 }1867 }
1679 storeCloseTerminal(activeThreadId, terminalId);1868 if (activeThreadRef) {
1869 storeCloseTerminal(activeThreadRef, terminalId);
1870 }
1680 setTerminalFocusRequestId((value) => value + 1);1871 setTerminalFocusRequestId((value) => value + 1);
1681 },1872 },
1682 [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length],1873 [
1874 activeThreadId,
1875 activeThreadRef,
1876 environmentId,
1877 storeCloseTerminal,
1878 terminalState.terminalIds.length,
1879 ],
1683 );1880 );
1684 const runProjectScript = useCallback(1881 const runProjectScript = useCallback(
1685 async (1882 async (
@@ -1692,7 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines
1692 rememberAsLastInvoked?: boolean;1889 rememberAsLastInvoked?: boolean;
1693 },1890 },
1694 ) => {1891 ) => {
1695 const api = readNativeApi();1892 const api = readEnvironmentApi(environmentId);
1696 if (!api || !activeThreadId || !activeProject || !activeThread) return;1893 if (!api || !activeThreadId || !activeProject || !activeThread) return;
1697 if (options?.rememberAsLastInvoked !== false) {1894 if (options?.rememberAsLastInvoked !== false) {
1698 setLastInvokedScriptByProjectId((current) => {1895 setLastInvokedScriptByProjectId((current) => {
@@ -1719,10 +1916,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 20 unmodified lines
1719 worktreePath: targetWorktreePath,1916 worktreePath: targetWorktreePath,
1720 });1917 });
1721 setTerminalOpen(true);1918 setTerminalOpen(true);
1919 if (!activeThreadRef) {
1920 return;
1921 }
1722 if (shouldCreateNewTerminal) {1922 if (shouldCreateNewTerminal) {
1723 storeNewTerminal(activeThreadId, targetTerminalId);1923 storeNewTerminal(activeThreadRef, targetTerminalId);
1724 } else {1924 } else {
1725 storeSetActiveTerminal(activeThreadId, targetTerminalId);1925 storeSetActiveTerminal(activeThreadRef, targetTerminalId);
1726 }1926 }
1727 setTerminalFocusRequestId((value) => value + 1);1927 setTerminalFocusRequestId((value) => value + 1);
1728 1928
@@ -1769,12 +1969,14 @@ export default function ChatView({ threadId }: ChatViewProps) { · 40 unmodified lines
1769 activeProject,1969 activeProject,
1770 activeThread,1970 activeThread,
1771 activeThreadId,1971 activeThreadId,
1972 activeThreadRef,
1772 gitCwd,1973 gitCwd,
1773 setTerminalOpen,1974 setTerminalOpen,
1774 setThreadError,1975 setThreadError,
1775 storeNewTerminal,1976 storeNewTerminal,
1776 storeSetActiveTerminal,1977 storeSetActiveTerminal,
1777 setLastInvokedScriptByProjectId,1978 setLastInvokedScriptByProjectId,
1979 environmentId,
1778 terminalState.activeTerminalId,1980 terminalState.activeTerminalId,
1779 terminalState.runningTerminalIds,1981 terminalState.runningTerminalIds,
1780 terminalState.terminalIds,1982 terminalState.terminalIds,
@@ -1790,7 +1992,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines
1790 keybinding?: string | null;1992 keybinding?: string | null;
1791 keybindingCommand: KeybindingCommand;1993 keybindingCommand: KeybindingCommand;
1792 }) => {1994 }) => {
1793 const api = readNativeApi();1995 const api = readEnvironmentApi(environmentId);
1794 if (!api) return;1996 if (!api) return;
1795 1997
1796 await api.orchestration.dispatchCommand({1998 await api.orchestration.dispatchCommand({
@@ -1806,10 +2008,14 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines
1806 });2008 });
1807 2009
1808 if (isElectron && keybindingRule) {2010 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);
1810 }2016 }
1811 },2017 },
1812 [],2018 [environmentId],
1813 );2019 );
1814 const saveProjectScript = useCallback(2020 const saveProjectScript = useCallback(
1815 async (input: NewProjectScriptInput) => {2021 async (input: NewProjectScriptInput) => {
@@ -1913,9 +2119,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 97 unmodified lines
1913 const handleRuntimeModeChange = useCallback(2119 const handleRuntimeModeChange = useCallback(
1914 (mode: RuntimeMode) => {2120 (mode: RuntimeMode) => {
1915 if (mode === runtimeMode) return;2121 if (mode === runtimeMode) return;
1916 setComposerDraftRuntimeMode(threadId, mode);2122 setComposerDraftRuntimeMode(composerDraftTarget, mode);
1917 if (isLocalDraftThread) {2123 if (isLocalDraftThread) {
1918 setDraftThreadContext(threadId, { runtimeMode: mode });2124 setDraftThreadContext(composerDraftTarget, { runtimeMode: mode });
1919 }2125 }
1920 scheduleComposerFocus();2126 scheduleComposerFocus();
1921 },2127 },
@@ -1923,18 +2129,18 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line
1923 isLocalDraftThread,2129 isLocalDraftThread,
1924 runtimeMode,2130 runtimeMode,
1925 scheduleComposerFocus,2131 scheduleComposerFocus,
2132 composerDraftTarget,
1926 setComposerDraftRuntimeMode,2133 setComposerDraftRuntimeMode,
1927 setDraftThreadContext,2134 setDraftThreadContext,
1928 threadId,
1929 ],2135 ],
1930 );2136 );
1931 2137
1932 const handleInteractionModeChange = useCallback(2138 const handleInteractionModeChange = useCallback(
1933 (mode: ProviderInteractionMode) => {2139 (mode: ProviderInteractionMode) => {
1934 if (mode === interactionMode) return;2140 if (mode === interactionMode) return;
1935 setComposerDraftInteractionMode(threadId, mode);2141 setComposerDraftInteractionMode(composerDraftTarget, mode);
1936 if (isLocalDraftThread) {2142 if (isLocalDraftThread) {
1937 setDraftThreadContext(threadId, { interactionMode: mode });2143 setDraftThreadContext(composerDraftTarget, { interactionMode: mode });
1938 }2144 }
1939 scheduleComposerFocus();2145 scheduleComposerFocus();
1940 },2146 },
@@ -1942,9 +2148,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line
1942 interactionMode,2148 interactionMode,
1943 isLocalDraftThread,2149 isLocalDraftThread,
1944 scheduleComposerFocus,2150 scheduleComposerFocus,
2151 composerDraftTarget,
1945 setComposerDraftInteractionMode,2152 setComposerDraftInteractionMode,
1946 setDraftThreadContext,2153 setDraftThreadContext,
1947 threadId,
1948 ],2154 ],
1949 );2155 );
1950 const toggleInteractionMode = useCallback(() => {2156 const toggleInteractionMode = useCallback(() => {
@@ -1980,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 29 unmodified lines
1980 if (!serverThread) {2186 if (!serverThread) {
1981 return;2187 return;
1982 }2188 }
1983 const api = readNativeApi();2189 const api = readEnvironmentApi(environmentId);
1984 if (!api) {2190 if (!api) {
1985 return;2191 return;
1986 }2192 }
@@ -2020,7 +2226,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 33 unmodified lines
2020 });2226 });
2021 }2227 }
2022 },2228 },
2023 [serverThread],2229 [environmentId, serverThread],
2024 );2230 );
2025 2231
2026 // Auto-scroll on new messages2232 // Auto-scroll on new messages
@@ -2350,17 +2556,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 323 unmodified lines
2350 dragDepthRef.current = 0;2556 dragDepthRef.current = 0;
2351 setIsDragOverComposer(false);2557 setIsDragOverComposer(false);
2352 setExpandedImage(null);2558 setExpandedImage(null);
2353 }, [resetLocalDispatch, threadId]);2559 }, [draftId, resetLocalDispatch, threadId]);
2354 2560
2355 useEffect(() => {2561 useEffect(() => {
2356 let cancelled = false;2562 let cancelled = false;
2357 void (async () => {2563 void (async () => {
2358 if (composerImages.length === 0) {2564 if (composerImages.length === 0) {
2359 clearComposerDraftPersistedAttachments(threadId);2565 clearComposerDraftPersistedAttachments(composerDraftTarget);
2360 return;2566 return;
2361 }2567 }
2362 const getPersistedAttachmentsForThread = () =>2568 const getPersistedAttachmentsForThread = () =>
2363 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];2569 getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];
2364 try {2570 try {
2365 const currentPersistedAttachments = getPersistedAttachmentsForThread();2571 const currentPersistedAttachments = getPersistedAttachmentsForThread();
2366 const existingPersistedById = new Map(2572 const existingPersistedById = new Map(
@@ -2391,7 +2597,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 24 unmodified lines
2391 return;2597 return;
2392 }2598 }
2393 // Stage attachments in persisted draft state first so persist middleware can write them.2599 // Stage attachments in persisted draft state first so persist middleware can write them.
2394 syncComposerDraftPersistedAttachments(threadId, serialized);2600 syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);
2395 } catch {2601 } catch {
2396 const currentImageIds = new Set(composerImages.map((image) => image.id));2602 const currentImageIds = new Set(composerImages.map((image) => image.id));
2397 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();2603 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
@@ -2405,17 +2611,18 @@ export default function ChatView({ threadId }: ChatViewProps) { · 7 unmodified lines
2405 if (cancelled) {2611 if (cancelled) {
2406 return;2612 return;
2407 }2613 }
2408 syncComposerDraftPersistedAttachments(threadId, fallbackAttachments);2614 syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);
2409 }2615 }
2410 })();2616 })();
2411 return () => {2617 return () => {
2412 cancelled = true;2618 cancelled = true;
2413 };2619 };
2414 }, [2620 }, [
2621 composerDraftTarget,
2415 clearComposerDraftPersistedAttachments,2622 clearComposerDraftPersistedAttachments,
2416 composerImages,2623 composerImages,
2624 getComposerDraft,
2417 syncComposerDraftPersistedAttachments,2625 syncComposerDraftPersistedAttachments,
2418 threadId,
2419 ]);2626 ]);
2420 2627
2421 const closeExpandedImage = useCallback(() => {2628 const closeExpandedImage = useCallback(() => {
@@ -2476,7 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 54 unmodified lines
2476 useEffect(() => {2683 useEffect(() => {
2477 if (!activeThreadId) {2684 if (!activeThreadId) {
2478 setTerminalLaunchContext(null);2685 setTerminalLaunchContext(null);
2479 storeClearTerminalLaunchContext(threadId);2686 storeClearTerminalLaunchContext(routeThreadRef);
2480 return;2687 return;
2481 }2688 }
2482 setTerminalLaunchContext((current) => {2689 setTerminalLaunchContext((current) => {
@@ -2484,7 +2691,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line
2484 if (current.threadId === activeThreadId) return current;2691 if (current.threadId === activeThreadId) return current;
2485 return null;2692 return null;
2486 });2693 });
2487 }, [activeThreadId, storeClearTerminalLaunchContext, threadId]);2694 }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]);
2488 2695
2489 useEffect(() => {2696 useEffect(() => {
2490 if (!activeThreadId || !activeProjectCwd) {2697 if (!activeThreadId || !activeProjectCwd) {
@@ -2502,12 +2709,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 11 unmodified lines
2502 settledCwd === current.cwd &&2709 settledCwd === current.cwd &&
2503 (activeThreadWorktreePath ?? null) === current.worktreePath2710 (activeThreadWorktreePath ?? null) === current.worktreePath
2504 ) {2711 ) {
2505 storeClearTerminalLaunchContext(activeThreadId);2712 if (activeThreadRef) {
2713 storeClearTerminalLaunchContext(activeThreadRef);
2714 }
2506 return null;2715 return null;
2507 }2716 }
2508 return current;2717 return current;
2509 });2718 });
2510 }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]);2719 }, [
2720 activeProjectCwd,
2721 activeThreadId,
2722 activeThreadRef,
2723 activeThreadWorktreePath,
2724 storeClearTerminalLaunchContext,
2725 ]);
2511 2726
2512 useEffect(() => {2727 useEffect(() => {
2513 if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) {2728 if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) {
@@ -2521,11 +2736,14 @@ export default function ChatView({ threadId }: ChatViewProps) { · 7 unmodified lines
2521 settledCwd === storeServerTerminalLaunchContext.cwd &&2736 settledCwd === storeServerTerminalLaunchContext.cwd &&
2522 (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath2737 (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath
2523 ) {2738 ) {
2524 storeClearTerminalLaunchContext(activeThreadId);2739 if (activeThreadRef) {
2740 storeClearTerminalLaunchContext(activeThreadRef);
2741 }
2525 }2742 }
2526 }, [2743 }, [
2527 activeProjectCwd,2744 activeProjectCwd,
2528 activeThreadId,2745 activeThreadId,
2746 activeThreadRef,
2529 activeThreadWorktreePath,2747 activeThreadWorktreePath,
2530 storeClearTerminalLaunchContext,2748 storeClearTerminalLaunchContext,
2531 storeServerTerminalLaunchContext,2749 storeServerTerminalLaunchContext,
@@ -2535,11 +2753,16 @@ export default function ChatView({ threadId }: ChatViewProps) { · 3 unmodified lines
2535 if (terminalState.terminalOpen) {2753 if (terminalState.terminalOpen) {
2536 return;2754 return;
2537 }2755 }
2538 if (activeThreadId) {2756 if (activeThreadRef) {
2539 storeClearTerminalLaunchContext(activeThreadId);2757 storeClearTerminalLaunchContext(activeThreadRef);
2540 }2758 }
2541 setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current));2759 setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current));
2542 }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]);2760 }, [
2761 activeThreadId,
2762 activeThreadRef,
2763 storeClearTerminalLaunchContext,
2764 terminalState.terminalOpen,
2765 ]);
2543 2766
2544 useEffect(() => {2767 useEffect(() => {
2545 if (phase !== "running") return;2768 if (phase !== "running") return;
@@ -2552,16 +2775,16 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines
2552 }, [phase]);2775 }, [phase]);
2553 2776
2554 useEffect(() => {2777 useEffect(() => {
2555 if (!activeThreadId) return;2778 if (!activeThreadKey) return;
2556 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;2779 const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false;
2557 const current = Boolean(terminalState.terminalOpen);2780 const current = Boolean(terminalState.terminalOpen);
2558 2781
2559 if (!previous && current) {2782 if (!previous && current) {
2560 terminalOpenByThreadRef.current[activeThreadId] = current;2783 terminalOpenByThreadRef.current[activeThreadKey] = current;
2561 setTerminalFocusRequestId((value) => value + 1);2784 setTerminalFocusRequestId((value) => value + 1);
2562 return;2785 return;
2563 } else if (previous && !current) {2786 } else if (previous && !current) {
2564 terminalOpenByThreadRef.current[activeThreadId] = current;2787 terminalOpenByThreadRef.current[activeThreadKey] = current;
2565 const frame = window.requestAnimationFrame(() => {2788 const frame = window.requestAnimationFrame(() => {
2566 focusComposer();2789 focusComposer();
2567 });2790 });
@@ -2570,8 +2793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines
2570 };2793 };
2571 }2794 }
2572 2795
2573 terminalOpenByThreadRef.current[activeThreadId] = current;2796 terminalOpenByThreadRef.current[activeThreadKey] = current;
2574 }, [activeThreadId, focusComposer, terminalState.terminalOpen]);2797 }, [activeThreadKey, focusComposer, terminalState.terminalOpen]);
2575 2798
2576 useEffect(() => {2799 useEffect(() => {
2577 const handler = (event: globalThis.KeyboardEvent) => {2800 const handler = (event: globalThis.KeyboardEvent) => {
@@ -2766,14 +2989,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 188 unmodified lines
2766 2989
2767 const onRevertToTurnCount = useCallback(2990 const onRevertToTurnCount = useCallback(
2768 async (turnCount: number) => {2991 async (turnCount: number) => {
2769 const api = readNativeApi();2992 const api = readEnvironmentApi(environmentId);
2770 if (!api || !activeThread || isRevertingCheckpoint) return;2993 const localApi = readLocalApi();
2994 if (!api || !localApi || !activeThread || isRevertingCheckpoint) return;
2771 2995
2772 if (phase === "running" || isSendBusy || isConnecting) {2996 if (phase === "running" || isSendBusy || isConnecting) {
2773 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");2997 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
2774 return;2998 return;
2775 }2999 }
2776 const confirmed = await api.dialogs.confirm(3000 const confirmed = await localApi.dialogs.confirm(
2777 [3001 [
2778 `Revert this thread to checkpoint ${turnCount}?`,3002 `Revert this thread to checkpoint ${turnCount}?`,
2779 "This will discard newer messages and turn diffs in this thread.",3003 "This will discard newer messages and turn diffs in this thread.",
@@ -2802,12 +3026,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 22 unmodified lines
2802 }3026 }
2803 setIsRevertingCheckpoint(false);3027 setIsRevertingCheckpoint(false);
2804 },3028 },
2805 [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],3029 [
3030 activeThread,
3031 environmentId,
3032 isConnecting,
3033 isRevertingCheckpoint,
3034 isSendBusy,
3035 phase,
3036 setThreadError,
3037 ],
2806 );3038 );
2807 3039
2808 const onSend = async (e?: { preventDefault: () => void }) => {3040 const onSend = async (e?: { preventDefault: () => void }) => {
2809 e?.preventDefault();3041 e?.preventDefault();
2810 const api = readNativeApi();3042 const api = readEnvironmentApi(environmentId);
2811 if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return;3043 if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return;
2812 if (activePendingProgress) {3044 if (activePendingProgress) {
2813 onAdvanceActivePendingUserInput();3045 onAdvanceActivePendingUserInput();
@@ -2830,7 +3062,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines
2830 planMarkdown: activeProposedPlan.planMarkdown,3062 planMarkdown: activeProposedPlan.planMarkdown,
2831 });3063 });
2832 promptRef.current = "";3064 promptRef.current = "";
2833 clearComposerDraftContent(activeThread.id);3065 clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id));
2834 setComposerHighlightedItemId(null);3066 setComposerHighlightedItemId(null);
2835 setComposerCursor(0);3067 setComposerCursor(0);
2836 setComposerTrigger(null);3068 setComposerTrigger(null);
@@ -2847,7 +3079,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 10 unmodified lines
2847 if (standaloneSlashCommand) {3079 if (standaloneSlashCommand) {
2848 handleInteractionModeChange(standaloneSlashCommand);3080 handleInteractionModeChange(standaloneSlashCommand);
2849 promptRef.current = "";3081 promptRef.current = "";
2850 clearComposerDraftContent(activeThread.id);3082 clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id));
2851 setComposerHighlightedItemId(null);3083 setComposerHighlightedItemId(null);
2852 setComposerCursor(0);3084 setComposerCursor(0);
2853 setComposerTrigger(null);3085 setComposerTrigger(null);
@@ -2880,10 +3112,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 26 unmodified lines
2880 const shouldCreateWorktree =3112 const shouldCreateWorktree =
2881 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;3113 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
2882 if (shouldCreateWorktree && !activeThread.branch) {3114 if (shouldCreateWorktree && !activeThread.branch) {
2883 setStoreThreadError(3115 setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
2884 threadIdForSend,
2885 "Select a base branch before sending in New worktree mode.",
2886 );
2887 return;3116 return;
2888 }3117 }
2889 3118
@@ -2950,7 +3179,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 60 unmodified lines
2950 });3179 });
2951 }3180 }
2952 promptRef.current = "";3181 promptRef.current = "";
2953 clearComposerDraftContent(threadIdForSend);3182 clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend));
2954 setComposerHighlightedItemId(null);3183 setComposerHighlightedItemId(null);
2955 setComposerCursor(0);3184 setComposerCursor(0);
2956 setComposerTrigger(null);3185 setComposerTrigger(null);
@@ -3087,7 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 130 unmodified lines
3087 };3316 };
3088 3317
3089 const onInterrupt = async () => {3318 const onInterrupt = async () => {
3090 const api = readNativeApi();3319 const api = readEnvironmentApi(environmentId);
3091 if (!api || !activeThread) return;3320 if (!api || !activeThread) return;
3092 await api.orchestration.dispatchCommand({3321 await api.orchestration.dispatchCommand({
3093 type: "thread.turn.interrupt",3322 type: "thread.turn.interrupt",
@@ -3099,7 +3328,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 5 unmodified lines
3099 3328
3100 const onRespondToApproval = useCallback(3329 const onRespondToApproval = useCallback(
3101 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {3330 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
3102 const api = readNativeApi();3331 const api = readEnvironmentApi(environmentId);
3103 if (!api || !activeThreadId) return;3332 if (!api || !activeThreadId) return;
3104 3333
3105 setRespondingRequestIds((existing) =>3334 setRespondingRequestIds((existing) =>
@@ -3122,12 +3351,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines
3122 });3351 });
3123 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));3352 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
3124 },3353 },
3125 [activeThreadId, setThreadError],3354 [activeThreadId, environmentId, setThreadError],
3126 );3355 );
3127 3356
3128 const onRespondToUserInput = useCallback(3357 const onRespondToUserInput = useCallback(
3129 async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => {3358 async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => {
3130 const api = readNativeApi();3359 const api = readEnvironmentApi(environmentId);
3131 if (!api || !activeThreadId) return;3360 if (!api || !activeThreadId) return;
3132 3361
3133 setRespondingUserInputRequestIds((existing) =>3362 setRespondingUserInputRequestIds((existing) =>
@@ -3150,7 +3379,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines
3150 });3379 });
3151 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));3380 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
3152 },3381 },
3153 [activeThreadId, setThreadError],3382 [activeThreadId, environmentId, setThreadError],
3154 );3383 );
3155 3384
3156 const setActivePendingUserInputQuestionIndex = useCallback(3385 const setActivePendingUserInputQuestionIndex = useCallback(
@@ -3166,31 +3395,38 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines
3166 [activePendingUserInput],3395 [activePendingUserInput],
3167 );3396 );
3168 3397
3169 const onToggleActivePendingUserInputOption = useCallback(3398 const onSelectActivePendingUserInputOption = useCallback(
3170 (questionId: string, optionLabel: string) => {3399 (questionId: string, optionLabel: string) => {
3171 if (!activePendingUserInput) {3400 if (!activePendingUserInput) {
3172 return;3401 return;
3173 }3402 }
3174 const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);3403 setPendingUserInputAnswersByRequestId((existing) => {
3175 if (!question) {3404 const question =
3176 return;3405 (activePendingProgress?.activeQuestion?.id === questionId
3177 }3406 ? activePendingProgress.activeQuestion
3178 setPendingUserInputAnswersByRequestId((existing) => ({3407 : undefined) ??
3179 ...existing,3408 activePendingUserInput.questions.find((entry) => entry.id === questionId);
3180 [activePendingUserInput.requestId]: {3409 if (!question) {
3181 ...existing[activePendingUserInput.requestId],3410 return existing;
3182 [questionId]: togglePendingUserInputOptionSelection(3411 }
3183 question,3412
3184 existing[activePendingUserInput.requestId]?.[questionId],3413 return {
3185 optionLabel,3414 ...existing,
3186 ),3415 [activePendingUserInput.requestId]: {
3187 },3416 ...existing[activePendingUserInput.requestId],
3188 }));3417 [questionId]: togglePendingUserInputOptionSelection(
3418 question,
3419 existing[activePendingUserInput.requestId]?.[questionId],
3420 optionLabel,
3421 ),
3422 },
3423 };
3424 });
3189 promptRef.current = "";3425 promptRef.current = "";
3190 setComposerCursor(0);3426 setComposerCursor(0);
3191 setComposerTrigger(null);3427 setComposerTrigger(null);
3192 },3428 },
3193 [activePendingUserInput],3429 [activePendingProgress?.activeQuestion, activePendingUserInput],
3194 );3430 );
3195 3431
3196 const onChangeActivePendingUserInputCustomAnswer = useCallback(3432 const onChangeActivePendingUserInputCustomAnswer = useCallback(
@@ -3257,7 +3493,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 60 unmodified lines
3257 text: string;3493 text: string;
3258 interactionMode: "default" | "plan";3494 interactionMode: "default" | "plan";
3259 }) => {3495 }) => {
3260 const api = readNativeApi();3496 const api = readEnvironmentApi(environmentId);
3261 if (3497 if (
3262 !api ||3498 !api ||
3263 !activeThread ||3499 !activeThread ||
@@ -3312,7 +3548,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 48 unmodified lines
3312 3548
3313 // Keep the mode toggle and plan-follow-up banner in sync immediately3549 // Keep the mode toggle and plan-follow-up banner in sync immediately
3314 // while the same-thread implementation turn is starting.3550 // while the same-thread implementation turn is starting.
3315 setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode);3551 setComposerDraftInteractionMode(
3552 scopeThreadRef(activeThread.environmentId, threadIdForSend),
3553 nextInteractionMode,
3554 );
3316 3555
3317 await api.orchestration.dispatchCommand({3556 await api.orchestration.dispatchCommand({
3318 type: "thread.turn.start",3557 type: "thread.turn.start",
@@ -3376,11 +3615,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 57 unmodified lines
3376 setComposerDraftInteractionMode,3615 setComposerDraftInteractionMode,
3377 setThreadError,3616 setThreadError,
3378 selectedModel,3617 selectedModel,
3618 environmentId,
3379 ],3619 ],
3380 );3620 );
3381 3621
3382 const onImplementPlanInNewThread = useCallback(async () => {3622 const onImplementPlanInNewThread = useCallback(async () => {
3383 const api = readNativeApi();3623 const api = readEnvironmentApi(environmentId);
3384 if (3624 if (
3385 !api ||3625 !api ||
3386 !activeThread ||3626 !activeThread ||
@@ -3452,17 +3692,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 65 unmodified lines
3452 });3692 });
3453 })3693 })
3454 .then(() => {3694 .then(() => {
3455 return waitForStartedServerThread(nextThreadId);3695 return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId));
3456 })3696 })
3457 .then(() => {3697 .then(() => {
3458 // Signal that the plan sidebar should open on the new thread.3698 // Signal that the plan sidebar should open on the new thread.
3459 planSidebarOpenOnNextThreadRef.current = true;3699 planSidebarOpenOnNextThreadRef.current = true;
3460 return navigate({3700 return navigate({
3461 to: "/$threadId",3701 to: "/$environmentId/$threadId",
3462 params: { threadId: nextThreadId },3702 params: {
3703 environmentId: activeThread.environmentId,
3704 threadId: nextThreadId,
3705 },
3463 });3706 });
3464 })3707 })
3465 .catch(async (err) => {3708 .catch(async (err: unknown) => {
3466 await api.orchestration3709 await api.orchestration
3467 .dispatchCommand({3710 .dispatchCommand({
3468 type: "thread.delete",3711 type: "thread.delete",
@@ -3494,6 +3737,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 25 unmodified lines
3494 selectedProvider,3737 selectedProvider,
3495 selectedProviderModels,3738 selectedProviderModels,
3496 selectedModel,3739 selectedModel,
3740 environmentId,
3497 ]);3741 ]);
3498 3742
3499 const onProviderModelSelect = useCallback(3743 const onProviderModelSelect = useCallback(
@@ -3514,7 +3758,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 14 unmodified lines
3514 provider: resolvedProvider,3758 provider: resolvedProvider,
3515 model: resolvedModel,3759 model: resolvedModel,
3516 };3760 };
3517 setComposerDraftModelSelection(activeThread.id, nextModelSelection);3761 setComposerDraftModelSelection(
3762 scopeThreadRef(activeThread.environmentId, activeThread.id),
3763 nextModelSelection,
3764 );
3518 setStickyComposerModelSelection(nextModelSelection);3765 setStickyComposerModelSelection(nextModelSelection);
3519 scheduleComposerFocus();3766 scheduleComposerFocus();
3520 },3767 },
@@ -3546,7 +3793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 25 unmodified lines
3546 );3793 );
3547 const providerTraitsMenuContent = renderProviderTraitsMenuContent({3794 const providerTraitsMenuContent = renderProviderTraitsMenuContent({
3548 provider: selectedProvider,3795 provider: selectedProvider,
3549 threadId,3796 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
3797 ...(routeKind === "draft" && draftId ? { draftId } : {}),
3550 model: selectedModel,3798 model: selectedModel,
3551 models: selectedProviderModels,3799 models: selectedProviderModels,
3552 modelOptions: composerModelOptions?.[selectedProvider],3800 modelOptions: composerModelOptions?.[selectedProvider],
@@ -3555,7 +3803,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines
3555 });3803 });
3556 const providerTraitsPicker = renderProviderTraitsPicker({3804 const providerTraitsPicker = renderProviderTraitsPicker({
3557 provider: selectedProvider,3805 provider: selectedProvider,
3558 threadId,3806 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
3807 ...(routeKind === "draft" && draftId ? { draftId } : {}),
3559 model: selectedModel,3808 model: selectedModel,
3560 models: selectedProviderModels,3809 models: selectedProviderModels,
3561 modelOptions: composerModelOptions?.[selectedProvider],3810 modelOptions: composerModelOptions?.[selectedProvider],
@@ -3565,11 +3814,11 @@ export default function ChatView({ threadId }: ChatViewProps) { · 3 unmodified lines
3565 const onEnvModeChange = useCallback(3814 const onEnvModeChange = useCallback(
3566 (mode: DraftThreadEnvMode) => {3815 (mode: DraftThreadEnvMode) => {
3567 if (isLocalDraftThread) {3816 if (isLocalDraftThread) {
3568 setDraftThreadContext(threadId, { envMode: mode });3817 setDraftThreadContext(composerDraftTarget, { envMode: mode });
3569 }3818 }
3570 scheduleComposerFocus();3819 scheduleComposerFocus();
3571 },3820 },
3572 [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId],3821 [composerDraftTarget, isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext],
3573 );3822 );
3574 3823
3575 const applyPromptReplacement = useCallback(3824 const applyPromptReplacement = useCallback(
@@ -3766,7 +4015,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 190 unmodified lines
3766 setPrompt(nextPrompt);4015 setPrompt(nextPrompt);
3767 if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) {4016 if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) {
3768 setComposerDraftTerminalContexts(4017 setComposerDraftTerminalContexts(
3769 threadId,4018 composerDraftTarget,
3770 syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds),4019 syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds),
3771 );4020 );
3772 }4021 }
@@ -3781,8 +4030,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 8 unmodified lines
3781 composerTerminalContexts,4030 composerTerminalContexts,
3782 onChangeActivePendingUserInputCustomAnswer,4031 onChangeActivePendingUserInputCustomAnswer,
3783 setPrompt,4032 setPrompt,
4033 composerDraftTarget,
3784 setComposerDraftTerminalContexts,4034 setComposerDraftTerminalContexts,
3785 threadId,
3786 ],4035 ],
3787 );4036 );
3788 4037
@@ -3835,9 +4084,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 46 unmodified lines
3835 const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null;4084 const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null;
3836 const onOpenTurnDiff = useCallback(4085 const onOpenTurnDiff = useCallback(
3837 (turnId: TurnId, filePath?: string) => {4086 (turnId: TurnId, filePath?: string) => {
4087 if (!isServerThread) {
4088 return;
4089 }
3838 void navigate({4090 void navigate({
3839 to: "/$threadId",4091 to: "/$environmentId/$threadId",
3840 params: { threadId },4092 params: {
4093 environmentId,
4094 threadId,
4095 },
3841 search: (previous) => {4096 search: (previous) => {
3842 const rest = stripDiffSearchParams(previous);4097 const rest = stripDiffSearchParams(previous);
3843 return filePath4098 return filePath
@@ -3846,7 +4101,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines
3846 },4101 },
3847 });4102 });
3848 },4103 },
3849 [navigate, threadId],4104 [environmentId, isServerThread, navigate, threadId],
3850 );4105 );
3851 const onRevertUserMessage = (messageId: MessageId) => {4106 const onRevertUserMessage = (messageId: MessageId) => {
3852 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);4107 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
@@ -3892,6 +4147,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 39 unmodified lines
3892 )}4147 )}
3893 >4148 >
3894 <ChatHeader4149 <ChatHeader
4150 activeThreadEnvironmentId={activeThread.environmentId}
3895 activeThreadId={activeThread.id}4151 activeThreadId={activeThread.id}
3896 activeThreadTitle={activeThread.title}4152 activeThreadTitle={activeThread.title}
3897 activeProjectName={activeProject?.name}4153 activeProjectName={activeProject?.name}
@@ -3959,6 +4215,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 61 unmodified lines
3959 completionSummary={completionSummary}4215 completionSummary={completionSummary}
3960 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}4216 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
3961 nowIso={nowIso}4217 nowIso={nowIso}
4218 activeThreadEnvironmentId={activeThread.environmentId}
3962 expandedWorkGroups={expandedWorkGroups}4219 expandedWorkGroups={expandedWorkGroups}
3963 onToggleWorkGroup={onToggleWorkGroup}4220 onToggleWorkGroup={onToggleWorkGroup}
3964 onOpenTurnDiff={onOpenTurnDiff}4221 onOpenTurnDiff={onOpenTurnDiff}
@@ -4027,7 +4284,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 62 unmodified lines
4027 respondingRequestIds={respondingRequestIds}4284 respondingRequestIds={respondingRequestIds}
4028 answers={activePendingDraftAnswers}4285 answers={activePendingDraftAnswers}
4029 questionIndex={activePendingQuestionIndex}4286 questionIndex={activePendingQuestionIndex}
4030 onToggleOption={onToggleActivePendingUserInputOption}4287 onToggleOption={onSelectActivePendingUserInputOption}
4031 onAdvance={onAdvanceActivePendingUserInput}4288 onAdvance={onAdvanceActivePendingUserInput}
4032 />4289 />
4033 </div>4290 </div>
@@ -4370,7 +4627,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 336 unmodified lines
4370 4627
4371 {isGitRepo && (4628 {isGitRepo && (
4372 <BranchToolbar4629 <BranchToolbar
4630 environmentId={activeThread.environmentId}
4373 threadId={activeThread.id}4631 threadId={activeThread.id}
4632 {...(routeKind === "draft" && draftId ? { draftId } : {})}
4374 onEnvModeChange={onEnvModeChange}4633 onEnvModeChange={onEnvModeChange}
4375 envLocked={envLocked}4634 envLocked={envLocked}
4376 onComposerFocusRequest={scheduleComposerFocus}4635 onComposerFocusRequest={scheduleComposerFocus}
@@ -4383,6 +4642,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines
4383 <PullRequestThreadDialog4642 <PullRequestThreadDialog
4384 key={pullRequestDialogState.key}4643 key={pullRequestDialogState.key}
4385 open4644 open
4645 environmentId={activeThread.environmentId}
4386 threadId={activeThread.id}4646 threadId={activeThread.id}
4387 cwd={activeProject?.cwd ?? null}4647 cwd={activeProject?.cwd ?? null}
4388 initialReference={pullRequestDialogState.initialReference}4648 initialReference={pullRequestDialogState.initialReference}
@@ -4402,6 +4662,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 13 unmodified lines
4402 <PlanSidebar4662 <PlanSidebar
4403 activePlan={activePlan}4663 activePlan={activePlan}
4404 activeProposedPlan={sidebarProposedPlan}4664 activeProposedPlan={sidebarProposedPlan}
4665 environmentId={environmentId}
4405 markdownCwd={gitCwd ?? undefined}4666 markdownCwd={gitCwd ?? undefined}
4406 workspaceRoot={activeWorkspaceRoot}4667 workspaceRoot={activeWorkspaceRoot}
4407 timestampFormat={timestampFormat}4668 timestampFormat={timestampFormat}
@@ -4418,21 +4679,28 @@ export default function ChatView({ threadId }: ChatViewProps) { · 10 unmodified lines
4418 </div>4679 </div>
4419 {/* end horizontal flex container */}4680 {/* end horizontal flex container */}
4420 4681
4421 {mountedTerminalThreadIds.map((mountedThreadId) => (4682 {mountedTerminalThreadKeys.flatMap((mountedThreadKey) => {
4422 <PersistentThreadTerminalDrawer4683 const mountedThreadRef = parseScopedThreadKey(mountedThreadKey);
4423 key={mountedThreadId}4684 if (!mountedThreadRef) {
4424 threadId={mountedThreadId}4685 return [];
4425 visible={mountedThreadId === activeThreadId && terminalState.terminalOpen}4686 }
4426 launchContext={4687 return [
4427 mountedThreadId === activeThreadId ? (activeTerminalLaunchContext ?? null) : null4688 <PersistentThreadTerminalDrawer
4428 }4689 key={mountedThreadKey}
4429 focusRequestId={mountedThreadId === activeThreadId ? terminalFocusRequestId : 0}4690 threadRef={mountedThreadRef}
4430 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}4691 threadId={mountedThreadRef.threadId}
4431 newShortcutLabel={newTerminalShortcutLabel ?? undefined}4692 visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen}
4432 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}4693 launchContext={
4433 onAddTerminalContext={addTerminalContextToDraft}4694 mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
4434 />4695 }
4435 ))}4696 focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0}
4697 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4698 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4699 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4700 onAddTerminalContext={addTerminalContextToDraft}
4701 />,
4702 ];
4703 })}
4436 4704
4437 {expandedImage && expandedImageItem && (4705 {expandedImage && expandedImageItem && (
4438 <div4706 <div
apps/web/src/components/DiffPanel.tsx+26−15
@@ -2,7 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs"; · 1 unmodified line
2import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";2import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
3import { useQuery } from "@tanstack/react-query";3import { useQuery } from "@tanstack/react-query";
4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5import { ThreadId, type TurnId } from "@t3tools/contracts";5import { scopeThreadRef } from "@t3tools/client-runtime";
6import type { TurnId } from "@t3tools/contracts";
6import {7import {
7 ChevronLeftIcon,8 ChevronLeftIcon,
8 ChevronRightIcon,9 ChevronRightIcon,
@@ -22,15 +23,16 @@ import { openInPreferredEditor } from "../editorPreferences"; · 13 unmodified lines
22import { useGitStatus } from "~/lib/gitStatusState";23import { useGitStatus } from "~/lib/gitStatusState";
23import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";24import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
24import { cn } from "~/lib/utils";25import { cn } from "~/lib/utils";
25import { readNativeApi } from "../nativeApi";26import { readLocalApi } from "../localApi";
26import { resolvePathLinkTarget } from "../terminal-links";27import { resolvePathLinkTarget } from "../terminal-links";
27import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";28import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
28import { useTheme } from "../hooks/useTheme";29import { useTheme } from "../hooks/useTheme";
29import { buildPatchCacheKey } from "../lib/diffRendering";30import { buildPatchCacheKey } from "../lib/diffRendering";
30import { resolveDiffThemeName } from "../lib/diffRendering";31import { resolveDiffThemeName } from "../lib/diffRendering";
31import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";32import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
32import { useStore } from "../store";33import { selectProjectByRef, useStore } from "../store";
33import { createThreadSelector } from "../storeSelectors";34import { createThreadSelectorByRef } from "../storeSelectors";
35import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
34import { useSettings } from "../hooks/useSettings";36import { useSettings } from "../hooks/useSettings";
35import { formatShortTimestamp } from "../timestampFormat";37import { formatShortTimestamp } from "../timestampFormat";
36import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";38import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
@@ -175,22 +177,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 138 unmodified lines
175 const previousDiffOpenRef = useRef(false);177 const previousDiffOpenRef = useRef(false);
176 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);178 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
177 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);179 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
178 const routeThreadId = useParams({180 const routeThreadRef = useParams({
179 strict: false,181 strict: false,
180 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),182 select: (params) => resolveThreadRouteRef(params),
181 });183 });
182 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });184 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
183 const diffOpen = diffSearch.diff === "1";185 const diffOpen = diffSearch.diff === "1";
184 const activeThreadId = routeThreadId;186 const activeThreadId = routeThreadRef?.threadId ?? null;
185 const activeThread = useStore(187 const activeThread = useStore(
186 useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]),188 useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]),
187 );189 );
188 const activeProjectId = activeThread?.projectId ?? null;190 const activeProjectId = activeThread?.projectId ?? null;
189 const activeProject = useStore((store) =>191 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,
191 );198 );
192 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;199 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 });
194 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;204 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
195 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =205 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
196 useTurnDiffSummaries(activeThread);206 useTurnDiffSummaries(activeThread);
@@ -263,6 +273,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 66 unmodified lines
263 }, [orderedTurnDiffSummaries, selectedTurn]);273 }, [orderedTurnDiffSummaries, selectedTurn]);
264 const activeCheckpointDiffQuery = useQuery(274 const activeCheckpointDiffQuery = useQuery(
265 checkpointDiffQueryOptions({275 checkpointDiffQueryOptions({
276 environmentId: activeThread?.environmentId ?? null,
266 threadId: activeThreadId,277 threadId: activeThreadId,
267 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,278 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
268 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,279 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
@@ -322,7 +333,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 53 unmodified lines
322 333
323 const openDiffFileInEditor = useCallback(334 const openDiffFileInEditor = useCallback(
324 (filePath: string) => {335 (filePath: string) => {
325 const api = readNativeApi();336 const api = readLocalApi();
326 if (!api) return;337 if (!api) return;
327 const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath;338 const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath;
328 void openInPreferredEditor(api, targetPath).catch((error) => {339 void openInPreferredEditor(api, targetPath).catch((error) => {
@@ -335,8 +346,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 6 unmodified lines
335 const selectTurn = (turnId: TurnId) => {346 const selectTurn = (turnId: TurnId) => {
336 if (!activeThread) return;347 if (!activeThread) return;
337 void navigate({348 void navigate({
338 to: "/$threadId",349 to: "/$environmentId/$threadId",
339 params: { threadId: activeThread.id },350 params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)),
340 search: (previous) => {351 search: (previous) => {
341 const rest = stripDiffSearchParams(previous);352 const rest = stripDiffSearchParams(previous);
342 return { ...rest, diff: "1", diffTurnId: turnId };353 return { ...rest, diff: "1", diffTurnId: turnId };
@@ -346,8 +357,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 3 unmodified lines
346 const selectWholeConversation = () => {357 const selectWholeConversation = () => {
347 if (!activeThread) return;358 if (!activeThread) return;
348 void navigate({359 void navigate({
349 to: "/$threadId",360 to: "/$environmentId/$threadId",
350 params: { threadId: activeThread.id },361 params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)),
351 search: (previous) => {362 search: (previous) => {
352 const rest = stripDiffSearchParams(previous);363 const rest = stripDiffSearchParams(previous);
353 return { ...rest, diff: "1" };364 return { ...rest, diff: "1" };
apps/web/src/components/GitActionsControl.browser.tsx+73−47
@@ -1,3 +1,4 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";
1import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
2import { useState } from "react";3import { useState } from "react";
3import { afterEach, describe, expect, it, vi } from "vitest";4import { afterEach, describe, expect, it, vi } from "vitest";
@@ -5,10 +6,24 @@ import { render } from "vitest-browser-react"; · 1 unmodified line
5 6
6const THREAD_A = ThreadId.makeUnsafe("thread-a");7const THREAD_A = ThreadId.makeUnsafe("thread-a");
7const THREAD_B = ThreadId.makeUnsafe("thread-b");8const THREAD_B = ThreadId.makeUnsafe("thread-b");
9const ENVIRONMENT_ID = "environment-local" as never;
8const GIT_CWD = "/repo/project";10const GIT_CWD = "/repo/project";
9const BRANCH_NAME = "feature/toast-scope";11const BRANCH_NAME = "feature/toast-scope";
10 12
13function createDeferredPromise<T>() {
14 let resolve!: (value: T) => void;
15 let reject!: (reason?: unknown) => void;
16
17 const promise = new Promise<T>((nextResolve, nextReject) => {
18 resolve = nextResolve;
19 reject = nextReject;
20 });
21
22 return { promise, resolve, reject };
23}
24
11const {25const {
26 activeRunStackedActionDeferredRef,
12 invalidateGitQueriesSpy,27 invalidateGitQueriesSpy,
13 refreshGitStatusSpy,28 refreshGitStatusSpy,
14 runStackedActionMutateAsyncSpy,29 runStackedActionMutateAsyncSpy,
@@ -18,9 +33,10 @@ const { · 3 unmodified lines
18 toastPromiseSpy,33 toastPromiseSpy,
19 toastUpdateSpy,34 toastUpdateSpy,
20} = vi.hoisted(() => ({35} = vi.hoisted(() => ({
36 activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },
21 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),37 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),
22 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),38 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),
23 runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)),39 runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),
24 setThreadBranchSpy: vi.fn(),40 setThreadBranchSpy: vi.fn(),
25 toastAddSpy: vi.fn(() => "toast-1"),41 toastAddSpy: vi.fn(() => "toast-1"),
26 toastCloseSpy: vi.fn(),42 toastCloseSpy: vi.fn(),
@@ -28,12 +44,8 @@ const { · 1 unmodified line
28 toastUpdateSpy: vi.fn(),44 toastUpdateSpy: vi.fn(),
29}));45}));
30 46
31vi.mock("@tanstack/react-query", async () => {47vi.mock("@tanstack/react-query", () => {
32 const actual =
33 await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
34
35 return {48 return {
36 ...actual,
37 useIsMutating: vi.fn(() => 0),49 useIsMutating: vi.fn(() => 0),
38 useMutation: vi.fn((options: { __kind?: string }) => {50 useMutation: vi.fn((options: { __kind?: string }) => {
39 if (options.__kind === "run-stacked-action") {51 if (options.__kind === "run-stacked-action") {
@@ -56,27 +68,7 @@ vi.mock("@tanstack/react-query", async () => { · 16 unmodified lines
56 isPending: false,68 isPending: false,
57 };69 };
58 }),70 }),
59 useQuery: vi.fn((options: { queryKey?: string[] }) => {71 useQuery: vi.fn(() => ({ data: null, error: null })),
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 }),
80 useQueryClient: vi.fn(() => ({})),72 useQueryClient: vi.fn(() => ({})),
81 };73 };
82});74});
@@ -123,27 +115,44 @@ vi.mock("~/lib/gitStatusState", () => ({ · 40 unmodified lines
123 })),115 })),
124}));116}));
125 117
126vi.mock("~/lib/utils", async () => {118vi.mock("~/localApi", () => ({
127 const actual = await vi.importActual<typeof import("~/lib/utils")>("~/lib/utils");119 readLocalApi: vi.fn(() => null),
128
129 return {
130 ...actual,
131 newCommandId: vi.fn(() => "command-1"),
132 randomUUID: vi.fn(() => "action-1"),
133 };
134});
135
136vi.mock("~/nativeApi", () => ({
137 readNativeApi: vi.fn(() => null),
138}));120}));
139 121
140vi.mock("~/store", () => ({122vi.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 },
141 useStore: (selector: (state: unknown) => unknown) =>136 useStore: (selector: (state: unknown) => unknown) =>
142 selector({137 selector({
143 setThreadBranch: setThreadBranchSpy,138 setThreadBranch: setThreadBranchSpy,
144 threadShellById: {139 environmentStateById: {
145 [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null },140 [ENVIRONMENT_ID]: {
146 [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null },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 },
147 },156 },
148 }),157 }),
149}));158}));
@@ -168,7 +177,10 @@ function Harness() { · 18 unmodified lines
168 <button type="button" onClick={() => setActiveThreadId(THREAD_B)}>177 <button type="button" onClick={() => setActiveThreadId(THREAD_B)}>
169 Switch thread178 Switch thread
170 </button>179 </button>
171 <GitActionsControl gitCwd={GIT_CWD} activeThreadId={activeThreadId} />180 <GitActionsControl
181 gitCwd={GIT_CWD}
182 activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, activeThreadId)}
183 />
172 </>184 </>
173 );185 );
174}186}
@@ -177,6 +189,7 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 2 unmodified lines
177 afterEach(() => {189 afterEach(() => {
178 vi.useRealTimers();190 vi.useRealTimers();
179 vi.clearAllMocks();191 vi.clearAllMocks();
192 activeRunStackedActionDeferredRef.current = createDeferredPromise<never>();
180 document.body.innerHTML = "";193 document.body.innerHTML = "";
181 });194 });
182 195
@@ -231,6 +244,9 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 48 unmodified lines
231 }),244 }),
232 );245 );
233 } finally {246 } finally {
247 activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup"));
248 await Promise.resolve();
249 vi.useRealTimers();
234 await screen.unmount();250 await screen.unmount();
235 host.remove();251 host.remove();
236 }252 }
@@ -248,9 +264,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 11 unmodified lines
248 264
249 const host = document.createElement("div");265 const host = document.createElement("div");
250 document.body.append(host);266 document.body.append(host);
251 const screen = await render(<GitActionsControl gitCwd={GIT_CWD} activeThreadId={THREAD_A} />, {267 const screen = await render(
252 container: host,268 <GitActionsControl
253 });269 gitCwd={GIT_CWD}
270 activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, THREAD_A)}
271 />,
272 {
273 container: host,
274 },
275 );
254 276
255 try {277 try {
256 window.dispatchEvent(new Event("focus"));278 window.dispatchEvent(new Event("focus"));
@@ -264,11 +286,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 7 unmodified lines
264 286
265 await vi.advanceTimersByTimeAsync(1);287 await vi.advanceTimersByTimeAsync(1);
266 expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1);288 expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1);
267 expect(refreshGitStatusSpy).toHaveBeenCalledWith(GIT_CWD);289 expect(refreshGitStatusSpy).toHaveBeenCalledWith({
290 environmentId: ENVIRONMENT_ID,
291 cwd: GIT_CWD,
292 });
268 } finally {293 } finally {
269 if (originalVisibilityState) {294 if (originalVisibilityState) {
270 Object.defineProperty(document, "visibilityState", originalVisibilityState);295 Object.defineProperty(document, "visibilityState", originalVisibilityState);
271 }296 }
297 vi.useRealTimers();
272 await screen.unmount();298 await screen.unmount();
273 host.remove();299 host.remove();
274 }300 }
apps/web/src/components/GitActionsControl.tsx+42−20
@@ -1,9 +1,9 @@
1import { type ScopedThreadRef } from "@t3tools/contracts";
1import type {2import type {
2 GitActionProgressEvent,3 GitActionProgressEvent,
3 GitRunStackedActionResult,4 GitRunStackedActionResult,
4 GitStackedAction,5 GitStackedAction,
5 GitStatusResult,6 GitStatusResult,
6 ThreadId,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";8import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
9import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";9import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
@@ -49,12 +49,14 @@ import { · 39 unmodified lines
49import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";49import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
50import { newCommandId, randomUUID } from "~/lib/utils";50import { newCommandId, randomUUID } from "~/lib/utils";
51import { resolvePathLinkTarget } from "~/terminal-links";51import { resolvePathLinkTarget } from "~/terminal-links";
52import { readNativeApi } from "~/nativeApi";52import { readEnvironmentApi } from "~/environmentApi";
53import { readLocalApi } from "~/localApi";
53import { useStore } from "~/store";54import { useStore } from "~/store";
55import { createThreadSelectorByRef } from "~/storeSelectors";
54 56
55interface GitActionsControlProps {57interface GitActionsControlProps {
56 gitCwd: string | null;58 gitCwd: string | null;
57 activeThreadId: ThreadId | null;59 activeThreadRef: ScopedThreadRef | null;
58}60}
59 61
60interface PendingDefaultBranchAction {62interface PendingDefaultBranchAction {
@@ -206,14 +208,18 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { · 145 unmodified lines
206 return <InfoIcon className={iconClassName} />;208 return <InfoIcon className={iconClassName} />;
207}209}
208 210
209export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {211export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) {
212 const activeThreadId = activeThreadRef?.threadId ?? null;
213 const activeEnvironmentId = activeThreadRef?.environmentId ?? null;
210 const threadToastData = useMemo(214 const threadToastData = useMemo(
211 () => (activeThreadId ? { threadId: activeThreadId } : undefined),215 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
212 [activeThreadId],216 [activeThreadId],
213 );217 );
214 const activeServerThread = useStore((store) =>218 const activeServerThreadSelector = useMemo(
215 activeThreadId ? store.threadShellById[activeThreadId] : undefined,219 () => createThreadSelectorByRef(activeThreadRef),
220 [activeThreadRef],
216 );221 );
222 const activeServerThread = useStore(activeServerThreadSelector);
217 const setThreadBranch = useStore((store) => store.setThreadBranch);223 const setThreadBranch = useStore((store) => store.setThreadBranch);
218 const queryClient = useQueryClient();224 const queryClient = useQueryClient();
219 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);225 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
@@ -246,7 +252,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 26 unmodified lines
246 }252 }
247 253
248 const worktreePath = activeServerThread.worktreePath;254 const worktreePath = activeServerThread.worktreePath;
249 const api = readNativeApi();255 const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined;
250 if (api) {256 if (api) {
251 void api.orchestration257 void api.orchestration
252 .dispatchCommand({258 .dispatchCommand({
@@ -261,7 +267,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 8 unmodified lines
261 267
262 setThreadBranch(activeThreadId, branch, worktreePath);268 setThreadBranch(activeThreadId, branch, worktreePath);
263 },269 },
264 [activeServerThread, activeThreadId, setThreadBranch],270 [activeEnvironmentId, activeServerThread, activeThreadId, setThreadBranch],
265 );271 );
266 272
267 const syncThreadBranchAfterGitAction = useCallback(273 const syncThreadBranchAfterGitAction = useCallback(
@@ -276,7 +282,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 8 unmodified lines
276 [persistThreadBranchSync],282 [persistThreadBranchSync],
277 );283 );
278 284
279 const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd);285 const { data: gitStatus = null, error: gitStatusError } = useGitStatus({
286 environmentId: activeEnvironmentId,
287 cwd: gitCwd,
288 });
280 // Default to true while loading so we don't flash init controls.289 // Default to true while loading so we don't flash init controls.
281 const isRepo = gitStatus?.isRepo ?? true;290 const isRepo = gitStatus?.isRepo ?? true;
282 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;291 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
@@ -287,19 +296,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 4 unmodified lines
287 const allSelected = excludedFiles.size === 0;296 const allSelected = excludedFiles.size === 0;
288 const noneSelected = selectedFiles.length === 0;297 const noneSelected = selectedFiles.length === 0;
289 298
290 const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));299 const initMutation = useMutation(
300 gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),
301 );
291 302
292 const runImmediateGitActionMutation = useMutation(303 const runImmediateGitActionMutation = useMutation(
293 gitRunStackedActionMutationOptions({304 gitRunStackedActionMutationOptions({
305 environmentId: activeEnvironmentId,
294 cwd: gitCwd,306 cwd: gitCwd,
295 queryClient,307 queryClient,
296 }),308 }),
297 );309 );
298 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));310 const pullMutation = useMutation(
311 gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),
312 );
299 313
300 const isRunStackedActionRunning =314 const isRunStackedActionRunning =
301 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;315 useIsMutating({
302 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;316 mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd),
317 }) > 0;
318 const isPullRunning =
319 useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0;
303 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;320 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;
304 321
305 useEffect(() => {322 useEffect(() => {
@@ -372,7 +389,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 66 unmodified lines
372 }389 }
373 refreshTimeout = window.setTimeout(() => {390 refreshTimeout = window.setTimeout(() => {
374 refreshTimeout = null;391 refreshTimeout = null;
375 void refreshGitStatus(gitCwd).catch(() => undefined);392 void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch(
393 () => undefined,
394 );
376 }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS);395 }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS);
377 };396 };
378 const handleVisibilityChange = () => {397 const handleVisibilityChange = () => {
@@ -391,10 +410,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 12 unmodified lines
391 window.removeEventListener("focus", scheduleRefreshCurrentGitStatus);410 window.removeEventListener("focus", scheduleRefreshCurrentGitStatus);
392 document.removeEventListener("visibilitychange", handleVisibilityChange);411 document.removeEventListener("visibilitychange", handleVisibilityChange);
393 };412 };
394 }, [gitCwd]);413 }, [activeEnvironmentId, gitCwd]);
395 414
396 const openExistingPr = useCallback(async () => {415 const openExistingPr = useCallback(async () => {
397 const api = readNativeApi();416 const api = readLocalApi();
398 if (!api) {417 if (!api) {
399 toastManager.add({418 toastManager.add({
400 type: "error",419 type: "error",
@@ -412,7 +431,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 11 unmodified lines
412 });431 });
413 return;432 return;
414 }433 }
415 void api.shell.openExternal(prUrl).catch((err) => {434 void api.shell.openExternal(prUrl).catch((err: unknown) => {
416 toastManager.add({435 toastManager.add({
417 type: "error",436 type: "error",
418 title: "Unable to open PR link",437 title: "Unable to open PR link",
@@ -601,7 +620,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 182 unmodified lines
601 toastActionProps = {620 toastActionProps = {
602 children: toastCta.label,621 children: toastCta.label,
603 onClick: () => {622 onClick: () => {
604 const api = readNativeApi();623 const api = readLocalApi();
605 if (!api) return;624 if (!api) return;
606 closeResultToast();625 closeResultToast();
607 void api.shell.openExternal(toastCta.url);626 void api.shell.openExternal(toastCta.url);
@@ -760,7 +779,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 152 unmodified lines
760 779
761 const openChangedFileInEditor = useCallback(780 const openChangedFileInEditor = useCallback(
762 (filePath: string) => {781 (filePath: string) => {
763 const api = readNativeApi();782 const api = readLocalApi();
764 if (!api || !gitCwd) {783 if (!api || !gitCwd) {
765 toastManager.add({784 toastManager.add({
766 type: "error",785 type: "error",
@@ -836,7 +855,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 69 unmodified lines
836 <Menu855 <Menu
837 onOpenChange={(open) => {856 onOpenChange={(open) => {
838 if (open) {857 if (open) {
839 void refreshGitStatus(gitCwd).catch(() => undefined);858 void refreshGitStatus({
859 environmentId: activeEnvironmentId,
860 cwd: gitCwd,
861 }).catch(() => undefined);
840 }862 }
841 }}863 }}
842 >864 >
apps/web/src/components/KeybindingsToast.browser.tsx+26−23
@@ -2,6 +2,7 @@ import "../index.css"; · 1 unmodified line
2 2
3import {3import {
4 DEFAULT_SERVER_SETTINGS,4 DEFAULT_SERVER_SETTINGS,
5 EnvironmentId,
5 ORCHESTRATION_WS_METHODS,6 ORCHESTRATION_WS_METHODS,
6 type MessageId,7 type MessageId,
7 type OrchestrationReadModel,8 type OrchestrationReadModel,
@@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } · 10 unmodified lines
18import { render } from "vitest-browser-react";19import { render } from "vitest-browser-react";
19 20
20import { useComposerDraftStore } from "../composerDraftStore";21import { useComposerDraftStore } from "../composerDraftStore";
21import { __resetNativeApiForTests } from "../nativeApi";22import { __resetLocalApiForTests } from "../localApi";
22import { AppAtomRegistryProvider } from "../rpc/atomRegistry";23import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
23import { getServerConfig } from "../rpc/serverState";24import { getServerConfig } from "../rpc/serverState";
24import { getRouter } from "../router";25import { getRouter } from "../router";
@@ -34,6 +35,7 @@ vi.mock("../lib/gitStatusState", () => ({ · 9 unmodified lines
34 35
35const THREAD_ID = "thread-kb-toast-test" as ThreadId;36const THREAD_ID = "thread-kb-toast-test" as ThreadId;
36const PROJECT_ID = "project-1" as ProjectId;37const PROJECT_ID = "project-1" as ProjectId;
38const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
37const NOW_ISO = "2026-03-04T12:00:00.000Z";39const NOW_ISO = "2026-03-04T12:00:00.000Z";
38 40
39interface TestFixture {41interface TestFixture {
@@ -49,6 +51,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); · 9 unmodified lines
49 51
50function createBaseServerConfig(): ServerConfig {52function createBaseServerConfig(): ServerConfig {
51 return {53 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 },
52 cwd: "/repo/project",61 cwd: "/repo/project",
53 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",62 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
54 keybindings: [],63 keybindings: [],
@@ -155,6 +164,13 @@ function buildFixture(): TestFixture { · 100 unmodified lines
155 snapshot: createMinimalSnapshot(),164 snapshot: createMinimalSnapshot(),
156 serverConfig: createBaseServerConfig(),165 serverConfig: createBaseServerConfig(),
157 welcome: {166 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 },
158 cwd: "/repo/project",174 cwd: "/repo/project",
159 projectName: "Project",175 projectName: "Project",
160 bootstrapProjectId: PROJECT_ID,176 bootstrapProjectId: PROJECT_ID,
@@ -286,7 +302,9 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { · 125 unmodified lines
286 host.style.overflow = "hidden";302 host.style.overflow = "hidden";
287 document.body.append(host);303 document.body.append(host);
288 304
289 const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] }));305 const router = getRouter(
306 createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }),
307 );
290 308
291 const screen = await render(309 const screen = await render(
292 <AppAtomRegistryProvider>310 <AppAtomRegistryProvider>
@@ -347,32 +365,17 @@ describe("Keybindings update toast", () => { · 54 unmodified lines
347 return [];365 return [];
348 },366 },
349 });367 });
350 await __resetNativeApiForTests();368 await __resetLocalApiForTests();
351 localStorage.clear();369 localStorage.clear();
352 document.body.innerHTML = "";370 document.body.innerHTML = "";
353 useComposerDraftStore.setState({371 useComposerDraftStore.setState({
354 draftsByThreadId: {},372 draftsByThreadKey: {},
355 draftThreadsByThreadId: {},373 draftThreadsByThreadKey: {},
356 projectDraftThreadIdByProjectId: {},374 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
357 });375 });
358 useStore.setState({376 useStore.setState({
359 projectIds: [],377 activeEnvironmentId: null,
360 projectById: {},378 environmentStateById: {},
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,
376 });379 });
377 });380 });
378 381
apps/web/src/components/PlanSidebar.tsx+6−3
@@ -1,4 +1,5 @@
1import { memo, useState, useCallback } from "react";1import { memo, useState, useCallback } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";
2import { type TimestampFormat } from "@t3tools/contracts/settings";3import { type TimestampFormat } from "@t3tools/contracts/settings";
3import { Badge } from "./ui/badge";4import { Badge } from "./ui/badge";
4import { Button } from "./ui/button";5import { Button } from "./ui/button";
@@ -24,7 +25,7 @@ import { · 19 unmodified lines
24 stripDisplayedPlanMarkdown,25 stripDisplayedPlanMarkdown,
25} from "../proposedPlan";26} from "../proposedPlan";
26import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";27import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
27import { readNativeApi } from "~/nativeApi";28import { readEnvironmentApi } from "~/environmentApi";
28import { toastManager } from "./ui/toast";29import { toastManager } from "./ui/toast";
29import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";30import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
30 31
@@ -53,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode { · 22 unmodified lines
53interface PlanSidebarProps {54interface PlanSidebarProps {
54 activePlan: ActivePlanState | null;55 activePlan: ActivePlanState | null;
55 activeProposedPlan: LatestProposedPlanState | null;56 activeProposedPlan: LatestProposedPlanState | null;
57 environmentId: EnvironmentId;
56 markdownCwd: string | undefined;58 markdownCwd: string | undefined;
57 workspaceRoot: string | undefined;59 workspaceRoot: string | undefined;
58 timestampFormat: TimestampFormat;60 timestampFormat: TimestampFormat;
@@ -62,6 +64,7 @@ interface PlanSidebarProps { · 3 unmodified lines
62const PlanSidebar = memo(function PlanSidebar({64const PlanSidebar = memo(function PlanSidebar({
63 activePlan,65 activePlan,
64 activeProposedPlan,66 activeProposedPlan,
67 environmentId,
65 markdownCwd,68 markdownCwd,
66 workspaceRoot,69 workspaceRoot,
67 timestampFormat,70 timestampFormat,
@@ -87,7 +90,7 @@ const PlanSidebar = memo(function PlanSidebar({ · 19 unmodified lines
87 }, [planMarkdown]);90 }, [planMarkdown]);
88 91
89 const handleSaveToWorkspace = useCallback(() => {92 const handleSaveToWorkspace = useCallback(() => {
90 const api = readNativeApi();93 const api = readEnvironmentApi(environmentId);
91 if (!api || !workspaceRoot || !planMarkdown) return;94 if (!api || !workspaceRoot || !planMarkdown) return;
92 const filename = buildProposedPlanMarkdownFilename(planMarkdown);95 const filename = buildProposedPlanMarkdownFilename(planMarkdown);
93 setIsSavingToWorkspace(true);96 setIsSavingToWorkspace(true);
@@ -115,7 +118,7 @@ const PlanSidebar = memo(function PlanSidebar({ · 21 unmodified lines
115 () => setIsSavingToWorkspace(false),118 () => setIsSavingToWorkspace(false),
116 () => setIsSavingToWorkspace(false),119 () => setIsSavingToWorkspace(false),
117 );120 );
118 }, [planMarkdown, workspaceRoot]);121 }, [environmentId, planMarkdown, workspaceRoot]);
119 122
120 return (123 return (
121 <div className="flex h-full w-[340px] shrink-0 flex-col border-l border-border/70 bg-card/50">124 <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 @@
1import type { GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts";1import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useDebouncedValue } from "@tanstack/react-pacer";3import { useDebouncedValue } from "@tanstack/react-pacer";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner"; · 19 unmodified lines
24 24
25interface PullRequestThreadDialogProps {25interface PullRequestThreadDialogProps {
26 open: boolean;26 open: boolean;
27 environmentId: EnvironmentId;
27 threadId: ThreadId;28 threadId: ThreadId;
28 cwd: string | null;29 cwd: string | null;
29 initialReference: string | null;30 initialReference: string | null;
@@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps { · 3 unmodified lines
33 34
34export function PullRequestThreadDialog({35export function PullRequestThreadDialog({
35 open,36 open,
37 environmentId,
36 threadId,38 threadId,
37 cwd,39 cwd,
38 initialReference,40 initialReference,
@@ -72,6 +74,7 @@ export function PullRequestThreadDialog({ · 33 unmodified lines
72 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);74 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);
73 const resolvePullRequestQuery = useQuery(75 const resolvePullRequestQuery = useQuery(
74 gitResolvePullRequestQueryOptions({76 gitResolvePullRequestQueryOptions({
77 environmentId,
75 cwd,78 cwd,
76 reference: open ? parsedDebouncedReference : null,79 reference: open ? parsedDebouncedReference : null,
77 }),80 }),
@@ -83,13 +86,14 @@ export function PullRequestThreadDialog({ · 5 unmodified lines
83 const cached = queryClient.getQueryData<GitResolvePullRequestResult>([86 const cached = queryClient.getQueryData<GitResolvePullRequestResult>([
84 "git",87 "git",
85 "pull-request",88 "pull-request",
89 environmentId,
86 cwd,90 cwd,
87 parsedReference,91 parsedReference,
88 ]);92 ]);
89 return cached?.pullRequest ?? null;93 return cached?.pullRequest ?? null;
90 }, [cwd, parsedReference, queryClient]);94 }, [cwd, environmentId, parsedReference, queryClient]);
91 const preparePullRequestThreadMutation = useMutation(95 const preparePullRequestThreadMutation = useMutation(
92 gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }),96 gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }),
93 );97 );
94 98
95 const liveResolvedPullRequest =99 const liveResolvedPullRequest =
apps/web/src/components/Sidebar.logic.test.ts+5−1
@@ -20,7 +20,7 @@ import { · 19 unmodified lines
20 sortThreadsForSidebar,20 sortThreadsForSidebar,
21 THREAD_JUMP_HINT_SHOW_DELAY_MS,21 THREAD_JUMP_HINT_SHOW_DELAY_MS,
22} from "./Sidebar.logic";22} from "./Sidebar.logic";
23import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts";23import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts";
24import {24import {
25 DEFAULT_INTERACTION_MODE,25 DEFAULT_INTERACTION_MODE,
26 DEFAULT_RUNTIME_MODE,26 DEFAULT_RUNTIME_MODE,
@@ -28,6 +28,8 @@ import { · 1 unmodified line
28 type Thread,28 type Thread,
29} from "../types";29} from "../types";
30 30
31const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
32
31function makeLatestTurn(overrides?: {33function makeLatestTurn(overrides?: {
32 completedAt?: string | null;34 completedAt?: string | null;
33 startedAt?: string | null;35 startedAt?: string | null;
@@ -625,6 +627,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { · 591 unmodified lines
625 const { defaultModelSelection, ...rest } = overrides;627 const { defaultModelSelection, ...rest } = overrides;
626 return {628 return {
627 id: ProjectId.makeUnsafe("project-1"),629 id: ProjectId.makeUnsafe("project-1"),
630 environmentId: localEnvironmentId,
628 name: "Project",631 name: "Project",
629 cwd: "/tmp/project",632 cwd: "/tmp/project",
630 defaultModelSelection: {633 defaultModelSelection: {
@@ -642,6 +645,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { · 11 unmodified lines
642function makeThread(overrides: Partial<Thread> = {}): Thread {645function makeThread(overrides: Partial<Thread> = {}): Thread {
643 return {646 return {
644 id: ThreadId.makeUnsafe("thread-1"),647 id: ThreadId.makeUnsafe("thread-1"),
648 environmentId: localEnvironmentId,
645 codexThreadId: null,649 codexThreadId: null,
646 projectId: ProjectId.makeUnsafe("project-1"),650 projectId: ProjectId.makeUnsafe("project-1"),
647 title: "Thread",651 title: "Thread",
apps/web/src/components/Sidebar.tsx+2083−1402
@@ -12,20 +12,7 @@ import { · 11 unmodified lines
12} from "lucide-react";12} from "lucide-react";
13import { ProjectFavicon } from "./ProjectFavicon";13import { ProjectFavicon } from "./ProjectFavicon";
14import { autoAnimate } from "@formkit/auto-animate";14import { autoAnimate } from "@formkit/auto-animate";
15import {15import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
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";
29import { useShallow } from "zustand/react/shallow";16import { useShallow } from "zustand/react/shallow";
30import {17import {
31 DndContext,18 DndContext,
@@ -45,11 +32,20 @@ import { CSS } from "@dnd-kit/utilities"; · 13 unmodified lines
45import {32import {
46 DEFAULT_MODEL_BY_PROVIDER,33 DEFAULT_MODEL_BY_PROVIDER,
47 type DesktopUpdateState,34 type DesktopUpdateState,
35 type EnvironmentId,
48 ProjectId,36 ProjectId,
37 type ScopedThreadRef,
38 type ThreadEnvMode,
49 ThreadId,39 ThreadId,
50 type GitStatusResult,40 type GitStatusResult,
51} from "@t3tools/contracts";41} from "@t3tools/contracts";
52import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router";42import {
43 scopedProjectKey,
44 scopedThreadKey,
45 scopeProjectRef,
46 scopeThreadRef,
47} from "@t3tools/client-runtime";
48import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
53import {49import {
54 type SidebarProjectSortOrder,50 type SidebarProjectSortOrder,
55 type SidebarThreadSortOrder,51 type SidebarThreadSortOrder,
@@ -58,7 +54,13 @@ import { isElectron } from "../env"; · 2 unmodified lines
58import { APP_STAGE_LABEL, APP_VERSION } from "../branding";54import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
59import { isTerminalFocused } from "../lib/terminalFocus";55import { isTerminalFocused } from "../lib/terminalFocus";
60import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";56import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
61import { useStore } from "../store";57import {
58 selectProjectsAcrossEnvironments,
59 selectSidebarThreadsForProjectRef,
60 selectSidebarThreadsAcrossEnvironments,
61 selectThreadByRef,
62 useStore,
63} from "../store";
62import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";64import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
63import { useUiStateStore } from "../uiStateStore";65import { useUiStateStore } from "../uiStateStore";
64import {66import {
@@ -70,11 +72,16 @@ import { · 5 unmodified lines
70 threadTraversalDirectionFromCommand,72 threadTraversalDirectionFromCommand,
71} from "../keybindings";73} from "../keybindings";
72import { useGitStatus } from "../lib/gitStatusState";74import { useGitStatus } from "../lib/gitStatusState";
73import { readNativeApi } from "../nativeApi";75import { readLocalApi } from "../localApi";
74import { useComposerDraftStore } from "../composerDraftStore";76import { useComposerDraftStore } from "../composerDraftStore";
75import { useHandleNewThread } from "../hooks/useHandleNewThread";77import { useHandleNewThread } from "../hooks/useHandleNewThread";
76 78
77import { useThreadActions } from "../hooks/useThreadActions";79import { useThreadActions } from "../hooks/useThreadActions";
80import {
81 buildThreadRouteParams,
82 resolveThreadRouteRef,
83 resolveThreadRouteTarget,
84} from "../threadRoutes";
78import { toastManager } from "./ui/toast";85import { toastManager } from "./ui/toast";
79import { formatRelativeTimeLabel } from "../timestampFormat";86import { formatRelativeTimeLabel } from "../timestampFormat";
80import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";87import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
@@ -109,8 +116,6 @@ import { · 28 unmodified lines
109import { useThreadSelectionStore } from "../threadSelectionStore";116import { useThreadSelectionStore } from "../threadSelectionStore";
110import { isNonEmpty as isNonEmptyString } from "effect/String";117import { isNonEmpty as isNonEmptyString } from "effect/String";
111import {118import {
112 getVisibleSidebarThreadIds,
113 getVisibleThreadsForProject,
114 resolveAdjacentThreadId,119 resolveAdjacentThreadId,
115 isContextMenuPointerDown,120 isContextMenuPointerDown,
116 resolveProjectStatusIndicator,121 resolveProjectStatusIndicator,
@@ -123,12 +128,14 @@ import { · 6 unmodified lines
123 sortProjectsForSidebar,128 sortProjectsForSidebar,
124 sortThreadsForSidebar,129 sortThreadsForSidebar,
125 useThreadJumpHintVisibility,130 useThreadJumpHintVisibility,
131 ThreadStatusPill,
126} from "./Sidebar.logic";132} from "./Sidebar.logic";
127import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";133import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
128import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";134import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
135import { readEnvironmentApi } from "../environmentApi";
129import { useSettings, useUpdateSettings } from "~/hooks/useSettings";136import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
130import { useServerKeybindings } from "../rpc/serverState";137import { useServerKeybindings } from "../rpc/serverState";
131import type { Project } from "../types";138import type { Project, SidebarThreadSummary } from "../types";
132const THREAD_PREVIEW_LIMIT = 6;139const THREAD_PREVIEW_LIMIT = 6;
133const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {140const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
134 updated_at: "Last user message",141 updated_at: "Last user message",
@@ -143,9 +150,58 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { · 8 unmodified lines
143 duration: 180,150 duration: 180,
144 easing: "ease-out",151 easing: "ease-out",
145} as const;152} as const;
153const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();
154
155function 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
173function 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}
146 202
147type SidebarProjectSnapshot = Project & {203type SidebarProjectSnapshot = Project & {
148 expanded: boolean;204 projectKey: string;
149};205};
150interface TerminalStatusIndicator {206interface TerminalStatusIndicator {
151 label: "Terminal process running";207 label: "Terminal process running";
@@ -166,7 +222,7 @@ function ThreadStatusLabel({ · 14 unmodified lines
166 status,222 status,
167 compact = false,223 compact = false,
168}: {224}: {
169 status: NonNullable<ReturnType<typeof resolveThreadStatusPill>>;225 status: ThreadStatusPill;
170 compact?: boolean;226 compact?: boolean;
171}) {227}) {
172 if (compact) {228 if (compact) {
@@ -255,57 +311,81 @@ function resolveThreadPr( · 82 unmodified lines
255}311}
256 312
257interface SidebarThreadRowProps {313interface SidebarThreadRowProps {
258 threadId: ThreadId;314 thread: SidebarThreadSummary;
259 projectCwd: string | null;315 projectCwd: string | null;
260 orderedProjectThreadIds: readonly ThreadId[];316 orderedProjectThreadKeys: readonly string[];
261 routeThreadId: ThreadId | null;317 isActive: boolean;
262 selectedThreadIds: ReadonlySet<ThreadId>;
263 showThreadJumpHints: boolean;
264 jumpLabel: string | null;318 jumpLabel: string | null;
265 appSettingsConfirmThreadArchive: boolean;319 appSettingsConfirmThreadArchive: boolean;
266 renamingThreadId: ThreadId | null;320 renamingThreadKey: string | null;
267 renamingTitle: string;321 renamingTitle: string;
268 setRenamingTitle: (title: string) => void;322 setRenamingTitle: (title: string) => void;
269 onRenamingInputMount: (element: HTMLInputElement | null) => void;323 renamingInputRef: React.RefObject<HTMLInputElement | null>;
270 hasRenameCommitted: () => boolean;324 renamingCommittedRef: React.RefObject<boolean>;
271 markRenameCommitted: () => void;325 confirmingArchiveThreadKey: string | null;
272 confirmingArchiveThreadId: ThreadId | null;326 setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>;
273 setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>;327 confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>;
274 confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>;
275 handleThreadClick: (328 handleThreadClick: (
276 event: MouseEvent,329 event: React.MouseEvent,
277 threadId: ThreadId,330 threadRef: ScopedThreadRef,
278 orderedProjectThreadIds: readonly ThreadId[],331 orderedProjectThreadKeys: readonly string[],
279 ) => void;332 ) => void;
280 navigateToThread: (threadId: ThreadId) => void;333 navigateToThread: (threadRef: ScopedThreadRef) => void;
281 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;334 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
282 handleThreadContextMenu: (335 handleThreadContextMenu: (
283 threadId: ThreadId,336 threadRef: ScopedThreadRef,
284 position: { x: number; y: number },337 position: { x: number; y: number },
285 ) => Promise<void>;338 ) => Promise<void>;
286 clearSelection: () => void;339 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>;
288 cancelRename: () => void;345 cancelRename: () => void;
289 attemptArchiveThread: (threadId: ThreadId) => Promise<void>;346 attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>;
290 openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void;347 openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;
291}348}
292 349
293function SidebarThreadRow(props: SidebarThreadRowProps) {350const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
294 const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]);351 const {
295 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]);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);
296 const runningTerminalIds = useTerminalStateStore(380 const runningTerminalIds = useTerminalStateStore(
297 (state) =>381 (state) =>
298 selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds,382 selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds,
299 );383 );
300 const gitCwd = thread?.worktreePath ?? props.projectCwd;384 const gitCwd = thread.worktreePath ?? props.projectCwd;
301 const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null);385 const gitStatus = useGitStatus({
302 386 environmentId: thread.environmentId,
303 if (!thread) {387 cwd: thread.branch != null ? gitCwd : null,
304 return null;388 });
305 }
306
307 const isActive = props.routeThreadId === thread.id;
308 const isSelected = props.selectedThreadIds.has(thread.id);
309 const isHighlighted = isActive || isSelected;389 const isHighlighted = isActive || isSelected;
310 const isThreadRunning =390 const isThreadRunning =
311 thread.session?.status === "running" && thread.session.activeTurnId != null;391 thread.session?.status === "running" && thread.session.activeTurnId != null;
@@ -318,32 +398,173 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 6 unmodified lines
318 const pr = resolveThreadPr(thread.branch, gitStatus.data);398 const pr = resolveThreadPr(thread.branch, gitStatus.data);
319 const prStatus = prStatusIndicator(pr);399 const prStatus = prStatusIndicator(pr);
320 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);400 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
321 const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning;401 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
322 const threadMetaClassName = isConfirmingArchive402 const threadMetaClassName = isConfirmingArchive
323 ? "pointer-events-none opacity-0"403 ? "pointer-events-none opacity-0"
324 : !isThreadRunning404 : !isThreadRunning
325 ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"405 ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"
326 : "pointer-events-none";406 : "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} />, []);
327 558
328 return (559 return (
329 <SidebarMenuSubItem560 <SidebarMenuSubItem
330 className="w-full"561 className="w-full"
331 data-thread-item562 data-thread-item
332 onMouseLeave={() => {563 onMouseLeave={handleMouseLeave}
333 props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));564 onBlurCapture={handleBlurCapture}
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 }}
344 >565 >
345 <SidebarMenuSubButton566 <SidebarMenuSubButton
346 render={<div role="button" tabIndex={0} />}567 render={rowButtonRender}
347 size="sm"568 size="sm"
348 isActive={isActive}569 isActive={isActive}
349 data-testid={`thread-row-${thread.id}`}570 data-testid={`thread-row-${thread.id}`}
@@ -351,31 +572,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 1 unmodified line
351 isActive,572 isActive,
352 isSelected,573 isSelected,
353 })} relative isolate`}574 })} relative isolate`}
354 onClick={(event) => {575 onClick={handleRowClick}
355 props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds);576 onKeyDown={handleRowKeyDown}
356 }}577 onContextMenu={handleRowContextMenu}
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 }}
379 >578 >
380 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">579 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
381 {prStatus && (580 {prStatus && (
@@ -386,9 +585,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 4 unmodified lines
386 type="button"585 type="button"
387 aria-label={prStatus.tooltip}586 aria-label={prStatus.tooltip}
388 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}587 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) => {588 onClick={handlePrClick}
390 props.openPrLink(event, prStatus.url);
391 }}
392 >589 >
393 <GitPullRequestIcon className="size-3" />590 <GitPullRequestIcon className="size-3" />
394 </button>591 </button>
@@ -398,30 +595,15 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 3 unmodified lines
398 </Tooltip>595 </Tooltip>
399 )}596 )}
400 {threadStatus && <ThreadStatusLabel status={threadStatus} />}597 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
401 {props.renamingThreadId === thread.id ? (598 {renamingThreadKey === threadKey ? (
402 <input599 <input
403 ref={props.onRenamingInputMount}600 ref={handleRenameInputRef}
404 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"601 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
405 value={props.renamingTitle}602 value={renamingTitle}
406 onChange={(event) => props.setRenamingTitle(event.target.value)}603 onChange={handleRenameInputChange}
407 onKeyDown={(event) => {604 onKeyDown={handleRenameInputKeyDown}
408 event.stopPropagation();605 onBlur={handleRenameInputBlur}
409 if (event.key === "Enter") {606 onClick={handleRenameInputClick}
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()}
425 />607 />
426 ) : (608 ) : (
427 <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>609 <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
@@ -441,34 +623,19 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 13 unmodified lines
441 <div className="flex min-w-12 justify-end">623 <div className="flex min-w-12 justify-end">
442 {isConfirmingArchive ? (624 {isConfirmingArchive ? (
443 <button625 <button
444 ref={(element) => {626 ref={handleConfirmArchiveRef}
445 if (element) {
446 props.confirmArchiveButtonRefs.current.set(thread.id, element);
447 } else {
448 props.confirmArchiveButtonRefs.current.delete(thread.id);
449 }
450 }}
451 type="button"627 type="button"
452 data-thread-selection-safe628 data-thread-selection-safe
453 data-testid={`thread-archive-confirm-${thread.id}`}629 data-testid={`thread-archive-confirm-${thread.id}`}
454 aria-label={`Confirm archive ${thread.title}`}630 aria-label={`Confirm archive ${thread.title}`}
455 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"631 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) => {632 onPointerDown={stopPropagationOnPointerDown}
457 event.stopPropagation();633 onClick={handleConfirmArchiveClick}
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 }}
467 >634 >
468 Confirm635 Confirm
469 </button>636 </button>
470 ) : !isThreadRunning ? (637 ) : !isThreadRunning ? (
471 props.appSettingsConfirmThreadArchive ? (638 appSettingsConfirmThreadArchive ? (
472 <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">639 <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">
473 <button640 <button
474 type="button"641 type="button"
@@ -476,17 +643,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 1 unmodified line
476 data-testid={`thread-archive-${thread.id}`}643 data-testid={`thread-archive-${thread.id}`}
477 aria-label={`Archive ${thread.title}`}644 aria-label={`Archive ${thread.title}`}
478 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"645 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) => {646 onPointerDown={stopPropagationOnPointerDown}
480 event.stopPropagation();647 onClick={handleStartArchiveConfirmation}
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 }}
490 >648 >
491 <ArchiveIcon className="size-3.5" />649 <ArchiveIcon className="size-3.5" />
492 </button>650 </button>
@@ -502,14 +660,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 9 unmodified lines
502 data-testid={`thread-archive-${thread.id}`}660 data-testid={`thread-archive-${thread.id}`}
503 aria-label={`Archive ${thread.title}`}661 aria-label={`Archive ${thread.title}`}
504 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"662 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) => {663 onPointerDown={stopPropagationOnPointerDown}
506 event.stopPropagation();664 onClick={handleArchiveImmediateClick}
507 }}
508 onClick={(event) => {
509 event.preventDefault();
510 event.stopPropagation();
511 void props.attemptArchiveThread(thread.id);
512 }}
513 >665 >
514 <ArchiveIcon className="size-3.5" />666 <ArchiveIcon className="size-3.5" />
515 </button>667 </button>
@@ -521,12 +673,12 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 5 unmodified lines
521 )673 )
522 ) : null}674 ) : null}
523 <span className={threadMetaClassName}>675 <span className={threadMetaClassName}>
524 {props.showThreadJumpHints && props.jumpLabel ? (676 {jumpLabel ? (
525 <span677 <span
526 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"678 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}
528 >680 >
529 {props.jumpLabel}681 {jumpLabel}
530 </span>682 </span>
531 ) : (683 ) : (
532 <span684 <span
@@ -545,248 +697,288 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 12 unmodified lines
545 </SidebarMenuSubButton>697 </SidebarMenuSubButton>
546 </SidebarMenuSubItem>698 </SidebarMenuSubItem>
547 );699 );
700});
701
702interface 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;
548}747}
549 748
550function T3Wordmark() {749const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
551 return (750 props: SidebarProjectThreadListProps,
552 <svg751) {
553 aria-label="T3"752 const {
554 className="h-2.5 w-auto shrink-0 text-foreground"753 projectKey,
555 viewBox="15.5309 37 94.3941 56.96"754 projectExpanded,
556 xmlns="http://www.w3.org/2000/svg"755 hasOverflowingThreads,
557 >756 hiddenThreadStatus,
558 <path757 orderedProjectThreadKeys,
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"758 renderedThreads,
560 fill="currentColor"759 showEmptyThreadState,
561 />760 shouldShowThreadPanel,
562 </svg>761 isThreadListExpanded,
563 );762 projectCwd,
564}763 activeRouteThreadKey,
565 764 threadJumpLabelByKey,
566type SortableProjectHandleProps = Pick<765 appSettingsConfirmThreadArchive,
567 ReturnType<typeof useSortable>,766 renamingThreadKey,
568 "attributes" | "listeners" | "setActivatorNodeRef"767 renamingTitle,
569>;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" />, []);
570 789
571function 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}) {
582 return (790 return (
583 <Menu>791 <SidebarMenuSub
584 <Tooltip>792 ref={attachThreadListAutoAnimateRef}
585 <TooltipTrigger793 className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0"
586 render={794 >
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" />795 {shouldShowThreadPanel && showEmptyThreadState ? (
588 }796 <SidebarMenuSubItem className="w-full" data-thread-selection-safe>
589 >797 <div
590 <ArrowUpDownIcon className="size-3.5" />798 data-thread-selection-safe
591 </TooltipTrigger>799 className="flex h-6 w-full translate-x-0 items-center px-2 text-left text-[10px] text-muted-foreground/60"
592 <TooltipPopup side="right">Sort projects</TooltipPopup>800 >
593 </Tooltip>801 <span>No threads yet</span>
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
598 </div>802 </div>
599 <MenuRadioGroup803 </SidebarMenuSubItem>
600 value={projectSortOrder}804 ) : null}
601 onValueChange={(value) => {805 {shouldShowThreadPanel &&
602 onProjectSortOrderChange(value as SidebarProjectSortOrder);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);
603 }}847 }}
604 >848 >
605 {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map(849 <span className="flex min-w-0 flex-1 items-center gap-2">
606 ([value, label]) => (850 {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />}
607 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">851 <span>Show more</span>
608 {label}852 </span>
609 </MenuRadioItem>853 </SidebarMenuSubButton>
610 ),854 </SidebarMenuSubItem>
611 )}855 )}
612 </MenuRadioGroup>856 {projectExpanded && hasOverflowingThreads && isThreadListExpanded && (
613 </MenuGroup>857 <SidebarMenuSubItem className="w-full">
614 <MenuGroup>858 <SidebarMenuSubButton
615 <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground">859 render={showLessButtonRender}
616 Sort threads860 data-thread-selection-safe
617 </div>861 size="sm"
618 <MenuRadioGroup862 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"
619 value={threadSortOrder}863 onClick={() => {
620 onValueChange={(value) => {864 collapseThreadListForProject(projectKey);
621 onThreadSortOrderChange(value as SidebarThreadSortOrder);
622 }}865 }}
623 >866 >
624 {(867 <span>Show less</span>
625 Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]>868 </SidebarMenuSubButton>
626 ).map(([value, label]) => (869 </SidebarMenuSubItem>
627 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">870 )}
628 {label}871 </SidebarMenuSub>
629 </MenuRadioItem>
630 ))}
631 </MenuRadioGroup>
632 </MenuGroup>
633 </MenuPopup>
634 </Menu>
635 );872 );
873});
874
875interface 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;
636}892}
637 893
638function SortableProjectItem({894const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) {
639 projectId,
640 disabled = false,
641 children,
642}: {
643 projectId: ProjectId;
644 disabled?: boolean;
645 children: (handleProps: SortableProjectHandleProps) => ReactNode;
646}) {
647 const {895 const {
648 attributes,896 project,
649 listeners,897 isThreadListExpanded,
650 setActivatorNodeRef,898 activeRouteThreadKey,
651 setNodeRef,899 newThreadShortcutLabel,
652 transform,900 handleNewThread,
653 transition,901 archiveThread,
654 isDragging,902 deleteThread,
655 isOver,903 threadJumpLabelByKey,
656 } = useSortable({ id: projectId, disabled });904 attachThreadListAutoAnimateRef,
657 return (905 expandThreadListForProject,
658 <li906 collapseThreadListForProject,
659 ref={setNodeRef}907 dragInProgressRef,
660 style={{908 suppressProjectClickAfterDragRef,
661 transform: CSS.Translate.toString(transform),909 suppressProjectClickForContextMenuRef,
662 transition,910 isManualProjectSorting,
663 }}911 dragHandleProps,
664 className={`group/menu-item relative rounded-md ${912 } = props;
665 isDragging ? "z-20 opacity-80" : ""913 const threadSortOrder = useSettings<SidebarThreadSortOrder>(
666 } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`}914 (settings) => settings.sidebarThreadSortOrder,
667 data-sidebar="menu-item"
668 data-slot="sidebar-menu-item"
669 >
670 {children({ attributes, listeners, setActivatorNodeRef })}
671 </li>
672 );915 );
673}916 const appSettingsConfirmThreadDelete = useSettings<boolean>(
674 917 (settings) => settings.confirmThreadDelete,
675export default function Sidebar() {918 );
676 const projectIds = useStore((store) => store.projectIds);919 const appSettingsConfirmThreadArchive = useSettings<boolean>(
677 const projectById = useStore((store) => store.projectById);920 (settings) => settings.confirmThreadArchive,
678 const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById);921 );
679 const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId);922 const defaultThreadEnvMode = useSettings<ThreadEnvMode>(
680 const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore(923 (settings) => settings.defaultThreadEnvMode,
681 useShallow((store) => ({924 );
682 projectExpandedById: store.projectExpandedById,925 const router = useRouter();
683 projectOrder: store.projectOrder,926 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
684 threadLastVisitedAtById: store.threadLastVisitedAtById,927 const toggleProject = useUiStateStore((state) => state.toggleProject);
685 })),928 const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread);
686 );929 const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo);
687 const markThreadUnread = useUiStateStore((store) => store.markThreadUnread);930 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
688 const toggleProject = useUiStateStore((store) => store.toggleProject);931 const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection);
689 const reorderProjects = useUiStateStore((store) => store.reorderProjects);932 const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor);
690 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);933 const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size);
691 const getDraftThreadByProjectId = useComposerDraftStore(934 const clearComposerDraftForThread = useComposerDraftStore((state) => state.clearDraftThread);
692 (store) => store.getDraftThreadByProjectId,935 const getDraftThreadByProjectRef = useComposerDraftStore(
936 (state) => state.getDraftThreadByProjectRef,
693 );937 );
694 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
695 const clearProjectDraftThreadId = useComposerDraftStore(938 const clearProjectDraftThreadId = useComposerDraftStore(
696 (store) => store.clearProjectDraftThreadId,939 (state) => state.clearProjectDraftThreadId,
697 );940 );
698 const navigate = useNavigate();941 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
699 const pathname = useLocation({ select: (loc) => loc.pathname });942 threadId: ThreadId;
700 const isOnSettings = pathname.startsWith("/settings");943 }>({
701 const appSettings = useSettings();944 onCopy: (ctx) => {
702 const { updateSettings } = useUpdateSettings();945 toastManager.add({
703 const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread();946 type: "success",
704 const { archiveThread, deleteThread } = useThreadActions();947 title: "Thread ID copied",
705 const routeThreadId = useParams({948 description: ctx.threadId,
706 strict: false,949 });
707 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),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 },
708 });958 });
709 const keybindings = useServerKeybindings();959 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
710 const [addingProject, setAddingProject] = useState(false);960 path: string;
711 const [newCwd, setNewCwd] = useState("");961 }>({
712 const [isPickingFolder, setIsPickingFolder] = useState(false);962 onCopy: (ctx) => {
713 const [isAddingProject, setIsAddingProject] = useState(false);963 toastManager.add({
714 const [addProjectError, setAddProjectError] = useState<string | null>(null);964 type: "success",
715 const addProjectInputRef = useRef<HTMLInputElement | null>(null);965 title: "Path copied",
716 const [renamingProjectId, setRenamingProjectId] = useState<ProjectId | null>(null);966 description: ctx.path,
717 const [renamingProjectTitle, setRenamingProjectTitle] = useState("");967 });
718 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);968 },
719 const [renamingTitle, setRenamingTitle] = useState("");969 onError: (error) => {
720 const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);970 toastManager.add({
721 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<971 type: "error",
722 ReadonlySet<ProjectId>972 title: "Failed to copy path",
723 >(() => new Set());973 description: error instanceof Error ? error.message : "An error occurred.",
724 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();974 });
725 const projectRenamingCommittedRef = useRef(false);975 },
726 const projectRenamingInputRef = useRef<HTMLInputElement | null>(null);976 });
727 const renamingCommittedRef = useRef(false);977 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
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) => {
786 event.preventDefault();978 event.preventDefault();
787 event.stopPropagation();979 event.stopPropagation();
788 980
789 const api = readNativeApi();981 const api = readLocalApi();
790 if (!api) {982 if (!api) {
791 toastManager.add({983 toastManager.add({
792 type: "error",984 type: "error",
@@ -803,222 +995,495 @@ export default function Sidebar() { · 10 unmodified lines
803 });995 });
804 });996 });
805 }, []);997 }, []);
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]);
806 1082
807 const attemptArchiveThread = useCallback(1083 const pinnedCollapsedThread = useMemo(() => {
808 async (threadId: ThreadId) => {1084 const activeThreadKey = activeRouteThreadKey ?? undefined;
809 try {1085 if (!activeThreadKey || projectExpanded) {
810 await archiveThread(threadId);1086 return null;
811 } catch (error) {1087 }
812 toastManager.add({1088 return (
813 type: "error",1089 visibleProjectThreads.find(
814 title: "Failed to archive thread",1090 (thread) =>
815 description: error instanceof Error ? error.message : "An error occurred.",1091 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey,
816 });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();
817 }1178 }
1179 toggleProject(project.projectKey);
818 },1180 },
819 [archiveThread],1181 [
1182 clearSelection,
1183 dragInProgressRef,
1184 project.projectKey,
1185 selectedThreadCount,
1186 suppressProjectClickAfterDragRef,
1187 suppressProjectClickForContextMenuRef,
1188 toggleProject,
1189 ],
820 );1190 );
821 1191
822 const focusMostRecentThreadForProject = useCallback(1192 const handleProjectButtonKeyDown = useCallback(
823 (projectId: ProjectId) => {1193 (event: React.KeyboardEvent<HTMLButtonElement>) => {
824 const latestThread = sortThreadsForSidebar(1194 if (event.key !== "Enter" && event.key !== " ") return;
825 (threadIdsByProjectId[projectId] ?? [])1195 event.preventDefault();
826 .map((threadId) => sidebarThreadsById[threadId])1196 if (dragInProgressRef.current) {
827 .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined)1197 return;
828 .filter((thread) => thread.archivedAt === null),1198 }
829 appSettings.sidebarThreadSortOrder,1199 toggleProject(project.projectKey);
830 )[0];
831 if (!latestThread) return;
832
833 void navigate({
834 to: "/$threadId",
835 params: { threadId: latestThread.id },
836 });
837 },1200 },
838 [appSettings.sidebarThreadSortOrder, navigate, sidebarThreadsById, threadIdsByProjectId],1201 [dragInProgressRef, project.projectKey, toggleProject],
839 );1202 );
840 1203
841 const addProjectFromPath = useCallback(1204 const handleProjectButtonPointerDownCapture = useCallback(
842 async (rawCwd: string) => {1205 (event: React.PointerEvent<HTMLButtonElement>) => {
843 const cwd = rawCwd.trim();1206 suppressProjectClickForContextMenuRef.current = false;
844 if (!cwd || isAddingProject) return;1207 if (
845 const api = readNativeApi();1208 isContextMenuPointerDown({
846 if (!api) return;1209 button: event.button,
1210 ctrlKey: event.ctrlKey,
1211 isMac: isMacPlatform(navigator.platform),
1212 })
1213 ) {
1214 event.stopPropagation();
1215 }
847 1216
848 setIsAddingProject(true);1217 suppressProjectClickAfterDragRef.current = false;
849 const finishAddingProject = () => {1218 },
850 setIsAddingProject(false);1219 [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef],
851 setNewCwd("");1220 );
852 setAddProjectError(null);
853 setAddingProject(false);
854 };
855 1221
856 const existing = projects.find((project) => project.cwd === cwd);1222 const handleProjectButtonContextMenu = useCallback(
857 if (existing) {1223 (event: React.MouseEvent<HTMLButtonElement>) => {
858 focusMostRecentThreadForProject(existing.id);1224 event.preventDefault();
859 finishAddingProject();1225 suppressProjectClickForContextMenuRef.current = true;
860 return;1226 void (async () => {
861 }1227 const api = readLocalApi();
1228 if (!api) return;
862 1229
863 const projectId = newProjectId();1230 const clicked = await api.contextMenu.show(
864 const createdAt = new Date().toISOString();1231 [
865 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;1232 { id: "copy-path", label: "Copy Project Path" },
866 try {1233 { id: "delete", label: "Remove project", destructive: true },
867 await api.orchestration.dispatchCommand({1234 ],
868 type: "project.create",1235 {
869 commandId: newCommandId(),1236 x: event.clientX,
870 projectId,1237 y: event.clientY,
871 title,
872 workspaceRoot: cwd,
873 defaultModelSelection: {
874 provider: "codex",
875 model: DEFAULT_MODEL_BY_PROVIDER.codex,
876 },1238 },
877 createdAt,1239 );
878 });1240 if (clicked === "copy-path") {
879 await handleNewThread(projectId, {1241 copyPathToClipboard(project.cwd, { path: project.cwd });
880 envMode: appSettings.defaultThreadEnvMode,1242 return;
881 }).catch(() => undefined);1243 }
882 } catch (error) {1244 if (clicked !== "delete") return;
883 const description =1245
884 error instanceof Error ? error.message : "An error occurred while adding the project.";1246 if (projectThreads.length > 0) {
885 setIsAddingProject(false);1247 toastManager.add({
886 if (shouldBrowseForProjectImmediately) {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 });
887 toastManager.add({1279 toastManager.add({
888 type: "error",1280 type: "error",
889 title: "Failed to add project",1281 title: `Failed to remove "${project.name}"`,
890 description,1282 description: message,
891 });1283 });
892 } else {
893 setAddProjectError(description);
894 }1284 }
895 return;1285 })();
896 }
897 finishAddingProject();
898 },1286 },
899 [1287 [
900 focusMostRecentThreadForProject,1288 clearComposerDraftForThread,
901 handleNewThread,1289 clearProjectDraftThreadId,
902 isAddingProject,1290 copyPathToClipboard,
903 projects,1291 getDraftThreadByProjectRef,
904 shouldBrowseForProjectImmediately,1292 project.cwd,
905 appSettings.defaultThreadEnvMode,1293 project.environmentId,
1294 project.id,
1295 project.name,
1296 projectThreads.length,
1297 suppressProjectClickForContextMenuRef,
906 ],1298 ],
907 );1299 );
908 1300
909 const handleAddProject = () => {1301 const navigateToThread = useCallback(
910 void addProjectFromPath(newCwd);1302 (threadRef: ScopedThreadRef) => {
911 };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 );
912 1314
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;
914 1326
915 const handlePickFolder = async () => {1327 if (isModClick) {
916 const api = readNativeApi();1328 event.preventDefault();
917 if (!api || isPickingFolder) return;1329 toggleThreadSelection(threadKey);
918 setIsPickingFolder(true);1330 return;
919 let pickedPath: string | null = null;1331 }
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 }, []);
946 1332
947 const handleRenamingInputMount = useCallback((element: HTMLInputElement | null) => {1333 if (isShiftClick) {
948 if (element && renamingInputRef.current !== element) {1334 event.preventDefault();
949 renamingInputRef.current = element;1335 rangeSelectTo(threadKey, orderedProjectThreadKeys);
950 element.focus();1336 return;
951 element.select();1337 }
952 return;
953 }
954 if (element === null && renamingInputRef.current !== null) {
955 renamingInputRef.current = null;
956 }
957 }, []);
958 1338
959 const hasRenameCommitted = useCallback(() => renamingCommittedRef.current, []);1339 if (currentSelectionCount > 0) {
960 const markRenameCommitted = useCallback(() => {1340 clearSelection();
961 renamingCommittedRef.current = true;1341 }
962 }, []);1342 setSelectionAnchor(threadKey);
1343 void router.navigate({
1344 to: "/$environmentId/$threadId",
1345 params: buildThreadRouteParams(threadRef),
1346 });
1347 },
1348 [clearSelection, rangeSelectTo, router, setSelectionAnchor, toggleThreadSelection],
1349 );
963 1350
964 const cancelProjectRename = useCallback(() => {1351 const handleMultiSelectContextMenu = useCallback(
965 setRenamingProjectId(null);1352 async (position: { x: number; y: number }) => {
966 projectRenamingInputRef.current = null;1353 const api = readLocalApi();
967 }, []);1354 if (!api) return;
1355 const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
1356 if (threadKeys.length === 0) return;
1357 const count = threadKeys.length;
968 1358
969 const commitRename = useCallback(1359 const clicked = await api.contextMenu.show(
970 async (threadId: ThreadId, newTitle: string, originalTitle: string) => {1360 [
971 const finishRename = () => {1361 { id: "mark-unread", label: `Mark unread (${count})` },
972 setRenamingThreadId((current) => {1362 { id: "delete", label: `Delete (${count})`, destructive: true },
973 if (current !== threadId) return current;1363 ],
974 renamingInputRef.current = null;1364 position,
975 return null;1365 );
976 });
977 };
978 1366
979 const trimmed = newTitle.trim();1367 if (clicked === "mark-unread") {
980 if (trimmed.length === 0) {1368 for (const threadKey of threadKeys) {
981 toastManager.add({1369 const thread = sidebarThreadByKey.get(threadKey);
982 type: "warning",1370 markThreadUnread(threadKey, thread?.latestTurn?.completedAt);
983 title: "Thread title cannot be empty",1371 }
984 });1372 clearSelection();
985 finishRename();
986 return;1373 return;
987 }1374 }
988 if (trimmed === originalTitle) {1375
989 finishRename();1376 if (clicked !== "delete") return;
990 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;
991 }1386 }
992 const api = readNativeApi();1387
993 if (!api) {1388 const deletedThreadKeys = new Set(threadKeys);
994 finishRename();1389 for (const threadKey of threadKeys) {
995 return;1390 const thread = sidebarThreadByKey.get(threadKey);
1391 if (!thread) continue;
1392 await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
1393 deletedThreadKeys,
1394 });
996 }1395 }
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) => {
997 try {1462 try {
998 await api.orchestration.dispatchCommand({1463 await archiveThread(threadRef);
999 type: "thread.meta.update",
1000 commandId: newCommandId(),
1001 threadId,
1002 title: trimmed,
1003 });
1004 } catch (error) {1464 } catch (error) {
1005 toastManager.add({1465 toastManager.add({
1006 type: "error",1466 type: "error",
1007 title: "Failed to rename thread",1467 title: "Failed to archive thread",
1008 description: error instanceof Error ? error.message : "An error occurred.",1468 description: error instanceof Error ? error.message : "An error occurred.",
1009 });1469 });
1010 }1470 }
1011 finishRename();
1012 },1471 },
1013 [],1472 [archiveThread],
1014 );1473 );
1015 1474
1016 const commitProjectRename = useCallback(1475 const cancelRename = useCallback(() => {
1017 async (projectId: ProjectId, newTitle: string, originalTitle: string) => {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);
1018 const finishRename = () => {1483 const finishRename = () => {
1019 setRenamingProjectId((current) => {1484 setRenamingThreadKey((current) => {
1020 if (current !== projectId) return current;1485 if (current !== threadKey) return current;
1021 projectRenamingInputRef.current = null;1486 renamingInputRef.current = null;
1022 return null;1487 return null;
1023 });1488 });
1024 };1489 };
@@ -1027,7 +1492,7 @@ export default function Sidebar() { · 2 unmodified lines
1027 if (trimmed.length === 0) {1492 if (trimmed.length === 0) {
1028 toastManager.add({1493 toastManager.add({
1029 type: "warning",1494 type: "warning",
1030 title: "Project title cannot be empty",1495 title: "Thread title cannot be empty",
1031 });1496 });
1032 finishRename();1497 finishRename();
1033 return;1498 return;
@@ -1036,22 +1501,22 @@ export default function Sidebar() { · 2 unmodified lines
1036 finishRename();1501 finishRename();
1037 return;1502 return;
1038 }1503 }
1039 const api = readNativeApi();1504 const api = readEnvironmentApi(threadRef.environmentId);
1040 if (!api) {1505 if (!api) {
1041 finishRename();1506 finishRename();
1042 return;1507 return;
1043 }1508 }
1044 try {1509 try {
1045 await api.orchestration.dispatchCommand({1510 await api.orchestration.dispatchCommand({
1046 type: "project.meta.update",1511 type: "thread.meta.update",
1047 commandId: newCommandId(),1512 commandId: newCommandId(),
1048 projectId,1513 threadId: threadRef.threadId,
1049 title: trimmed,1514 title: trimmed,
1050 });1515 });
1051 } catch (error) {1516 } catch (error) {
1052 toastManager.add({1517 toastManager.add({
1053 type: "error",1518 type: "error",
1054 title: "Failed to rename project",1519 title: "Failed to rename thread",
1055 description: error instanceof Error ? error.message : "An error occurred.",1520 description: error instanceof Error ? error.message : "An error occurred.",
1056 });1521 });
1057 }1522 }
@@ -1060,50 +1525,19 @@ export default function Sidebar() { · 2 unmodified lines
1060 [],1525 [],
1061 );1526 );
1062 1527
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 });
1099 const handleThreadContextMenu = useCallback(1528 const handleThreadContextMenu = useCallback(
1100 async (threadId: ThreadId, position: { x: number; y: number }) => {1529 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
1101 const api = readNativeApi();1530 const api = readLocalApi();
1102 if (!api) return;1531 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;
1104 if (!thread) return;1539 if (!thread) return;
1105 const threadWorkspacePath =1540 const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null;
1106 thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null;
1107 const clicked = await api.contextMenu.show(1541 const clicked = await api.contextMenu.show(
1108 [1542 [
1109 { id: "rename", label: "Rename thread" },1543 { id: "rename", label: "Rename thread" },
@@ -1116,16 +1550,14 @@ export default function Sidebar() { · 6 unmodified lines
1116 );1550 );
1117 1551
1118 if (clicked === "rename") {1552 if (clicked === "rename") {
1119 setRenamingProjectId(null);1553 setRenamingThreadKey(threadKey);
1120 projectRenamingInputRef.current = null;
1121 setRenamingThreadId(threadId);
1122 setRenamingTitle(thread.title);1554 setRenamingTitle(thread.title);
1123 renamingCommittedRef.current = false;1555 renamingCommittedRef.current = false;
1124 return;1556 return;
1125 }1557 }
1126 1558
1127 if (clicked === "mark-unread") {1559 if (clicked === "mark-unread") {
1128 markThreadUnread(threadId, thread.latestTurn?.completedAt);1560 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
1129 return;1561 return;
1130 }1562 }
1131 if (clicked === "copy-path") {1563 if (clicked === "copy-path") {
@@ -1141,11 +1573,11 @@ export default function Sidebar() { · 9 unmodified lines
1141 return;1573 return;
1142 }1574 }
1143 if (clicked === "copy-thread-id") {1575 if (clicked === "copy-thread-id") {
1144 copyThreadIdToClipboard(threadId, { threadId });1576 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
1145 return;1577 return;
1146 }1578 }
1147 if (clicked !== "delete") return;1579 if (clicked !== "delete") return;
1148 if (appSettings.confirmThreadDelete) {1580 if (appSettingsConfirmThreadDelete) {
1149 const confirmed = await api.dialogs.confirm(1581 const confirmed = await api.dialogs.confirm(
1150 [1582 [
1151 `Delete thread "${thread.title}"?`,1583 `Delete thread "${thread.title}"?`,
@@ -1156,198 +1588,903 @@ export default function Sidebar() { · 4 unmodified lines
1156 return;1588 return;
1157 }1589 }
1158 }1590 }
1159 await deleteThread(threadId);1591 await deleteThread(threadRef);
1160 },1592 },
1161 [1593 [
1162 appSettings.confirmThreadDelete,1594 appSettingsConfirmThreadDelete,
1163 copyPathToClipboard,1595 copyPathToClipboard,
1164 copyThreadIdToClipboard,1596 copyThreadIdToClipboard,
1165 deleteThread,1597 deleteThread,
1166 markThreadUnread,1598 markThreadUnread,
1167 projectCwdById,1599 project.cwd,
1168 sidebarThreadsById,1600 projectThreads,
1169 ],1601 ],
1170 );1602 );
1171 1603
1172 const handleMultiSelectContextMenu = useCallback(1604 return (
1173 async (position: { x: number; y: number }) => {1605 <>
1174 const api = readNativeApi();1606 <div className="group/project-header relative">
1175 if (!api) return;1607 <SidebarMenuButton
1176 const ids = [...selectedThreadIds];1608 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
1177 if (ids.length === 0) return;1609 size="sm"
1178 const count = ids.length;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 ${
1179 1611 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
1180 const clicked = await api.contextMenu.show(1612 }`}
1181 [1613 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
1182 { id: "mark-unread", label: `Mark unread (${count})` },1614 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
1183 { id: "delete", label: `Delete (${count})`, destructive: true },1615 onPointerDownCapture={handleProjectButtonPointerDownCapture}
1184 ],1616 onClick={handleProjectButtonClick}
1185 position,1617 onKeyDown={handleProjectButtonKeyDown}
1186 );1618 onContextMenu={handleProjectButtonContextMenu}
1187 1619 >
1188 if (clicked === "mark-unread") {1620 {!projectExpanded && projectStatus ? (
1189 for (const id of ids) {1621 <span
1190 const thread = sidebarThreadsById[id];1622 aria-hidden="true"
1191 markThreadUnread(id, thread?.latestTurn?.completedAt);1623 title={projectStatus.label}
1192 }1624 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
1193 clearSelection();1625 >
1194 return;1626 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
1195 }1627 <span
1196 1628 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
1197 if (clicked !== "delete") return;1629 projectStatus.pulse ? "animate-pulse" : ""
1198 1630 }`}
1199 if (appSettings.confirmThreadDelete) {1631 />
1200 const confirmed = await api.dialogs.confirm(1632 </span>
1201 [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" />
1202 `Delete ${count} thread${count === 1 ? "" : "s"}?`,1634 </span>
1203 "This permanently clears conversation history for these threads.",1635 ) : (
1204 ].join("\n"),1636 <ChevronRightIcon
1205 );1637 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1206 if (!confirmed) return;1638 projectExpanded ? "rotate-90" : ""
1207 }1639 }`}
1208 1640 />
1209 const deletedIds = new Set<ThreadId>(ids);1641 )}
1210 for (const id of ids) {1642 <ProjectFavicon cwd={project.cwd} />
1211 await deleteThread(id, { deletedThreadIds: deletedIds });1643 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1212 }1644 {project.name}
1213 removeFromSelection(ids);1645 </span>
1214 },1646 </SidebarMenuButton>
1215 [1647 <Tooltip>
1216 appSettings.confirmThreadDelete,1648 <TooltipTrigger
1217 clearSelection,1649 render={
1218 deleteThread,1650 <SidebarMenuAction
1219 markThreadUnread,1651 render={
1220 removeFromSelection,1652 <button
1221 selectedThreadIds,1653 type="button"
1222 sidebarThreadsById,1654 aria-label={`Create new thread in ${project.name}`}
1223 ],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 </>
1224 );1708 );
1709});
1225 1710
1226 const handleThreadClick = useCallback(1711const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) {
1227 (event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => {1712 return (
1228 const isMac = isMacPlatform(navigator.platform);1713 <SidebarMenuItem className="rounded-md">
1229 const isModClick = isMac ? event.metaKey : event.ctrlKey;1714 <SidebarProjectItem {...props} />
1230 const isShiftClick = event.shiftKey;1715 </SidebarMenuItem>
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 ],
1262 );1716 );
1717});
1263 1718
1264 const navigateToThread = useCallback(1719function T3Wordmark() {
1265 (threadId: ThreadId) => {1720 return (
1266 if (selectedThreadIds.size > 0) {1721 <svg
1267 clearSelection();1722 aria-label="T3"
1268 }1723 className="h-2.5 w-auto shrink-0 text-foreground"
1269 setSelectionAnchor(threadId);1724 viewBox="15.5309 37 94.3941 56.96"
1270 void navigate({1725 xmlns="http://www.w3.org/2000/svg"
1271 to: "/$threadId",1726 >
1272 params: { threadId },1727 <path
1273 });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"
1274 },1729 fill="currentColor"
1275 [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor],1730 />
1731 </svg>
1276 );1732 );
1733}
1277 1734
1278 const handleProjectContextMenu = useCallback(1735type SortableProjectHandleProps = Pick<
1279 async (projectId: ProjectId, position: { x: number; y: number }) => {1736 ReturnType<typeof useSortable>,
1280 const api = readNativeApi();1737 "attributes" | "listeners" | "setActivatorNodeRef"
1281 if (!api) return;1738>;
1282 const project = projects.find((entry) => entry.id === projectId);
1283 if (!project) return;
1284 1739
1285 const clicked = await api.contextMenu.show(1740function ProjectSortMenu({
1286 [1741 projectSortOrder,
1287 { id: "rename", label: "Rename project" },1742 threadSortOrder,
1288 { id: "copy-path", label: "Copy Project Path" },1743 onProjectSortOrderChange,
1289 { id: "delete", label: "Remove project", destructive: true },1744 onThreadSortOrderChange,
1290 ],1745}: {
1291 position,1746 projectSortOrder: SidebarProjectSortOrder;
1292 );1747 threadSortOrder: SidebarThreadSortOrder;
1293 if (clicked === "rename") {1748 onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
1294 setRenamingThreadId(null);1749 onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
1295 renamingInputRef.current = null;1750}) {
1296 setRenamingProjectId(projectId);1751 return (
1297 setRenamingProjectTitle(project.name);1752 <Menu>
1298 projectRenamingCommittedRef.current = false;1753 <Tooltip>
1299 return;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
1807function 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
1844const 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
1886const 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
1911interface 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
1959const 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);
1300 }2035 }
1301 if (clicked === "copy-path") {2036 },
1302 copyPathToClipboard(project.cwd, { path: project.cwd });2037 [handleAddProject, setAddProjectError, setAddingProject],
1303 return;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
2231export 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]);
1304 }2319 }
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;
1306 2358
1307 const projectThreadIds = threadIdsByProjectId[projectId] ?? [];2359 void navigate({
1308 if (projectThreadIds.length > 0) {2360 to: "/$environmentId/$threadId",
1309 toastManager.add({2361 params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)),
1310 type: "warning",2362 });
1311 title: "Project is not empty",2363 },
1312 description: "Delete all threads in this project before removing it.",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,
1313 });2387 });
2388 finishAddingProject();
1314 return;2389 return;
1315 }2390 }
1316 2391
1317 const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`);2392 const projectId = newProjectId();
1318 if (!confirmed) return;2393 const createdAt = new Date().toISOString();
1319 2394 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
1320 try {2395 try {
1321 const projectDraftThread = getDraftThreadByProjectId(projectId);
1322 if (projectDraftThread) {
1323 clearComposerDraftForThread(projectDraftThread.threadId);
1324 }
1325 clearProjectDraftThreadId(projectId);
1326 await api.orchestration.dispatchCommand({2396 await api.orchestration.dispatchCommand({
1327 type: "project.delete",2397 type: "project.create",
1328 commandId: newCommandId(),2398 commandId: newCommandId(),
1329 projectId,2399 projectId,
2400 title,
2401 workspaceRoot: cwd,
2402 defaultModelSelection: {
2403 provider: "codex",
2404 model: DEFAULT_MODEL_BY_PROVIDER.codex,
2405 },
2406 createdAt,
1330 });2407 });
2408 if (activeEnvironmentId !== null) {
2409 await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), {
2410 envMode: appSettings.defaultThreadEnvMode,
2411 }).catch(() => undefined);
2412 }
1331 } catch (error) {2413 } catch (error) {
1332 const message = error instanceof Error ? error.message : "Unknown error removing project.";2414 const description =
1333 console.error("Failed to remove project", { projectId, error });2415 error instanceof Error ? error.message : "An error occurred while adding the project.";
1334 toastManager.add({2416 setIsAddingProject(false);
1335 type: "error",2417 if (shouldBrowseForProjectImmediately) {
1336 title: `Failed to remove "${project.name}"`,2418 toastManager.add({
1337 description: message,2419 type: "error",
1338 });2420 title: "Failed to add project",
2421 description,
2422 });
2423 } else {
2424 setAddProjectError(description);
2425 }
2426 return;
1339 }2427 }
2428 finishAddingProject();
1340 },2429 },
1341 [2430 [
1342 clearComposerDraftForThread,2431 focusMostRecentThreadForProject,
1343 clearProjectDraftThreadId,2432 activeEnvironmentId,
1344 copyPathToClipboard,2433 handleNewThread,
1345 getDraftThreadByProjectId,2434 isAddingProject,
1346 projects,2435 projects,
1347 threadIdsByProjectId,2436 shouldBrowseForProjectImmediately,
2437 appSettings.defaultThreadEnvMode,
1348 ],2438 ],
1349 );2439 );
1350 2440
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
1351 const projectDnDSensors = useSensors(2488 const projectDnDSensors = useSensors(
1352 useSensor(PointerSensor, {2489 useSensor(PointerSensor, {
1353 activationConstraint: { distance: 6 },2490 activationConstraint: { distance: 6 },
@@ -1371,10 +2508,10 @@ export default function Sidebar() { · 17 unmodified lines
1371 dragInProgressRef.current = false;2508 dragInProgressRef.current = false;
1372 const { active, over } = event;2509 const { active, over } = event;
1373 if (!over || active.id === over.id) return;2510 if (!over || active.id === over.id) return;
1374 const activeProject = sidebarProjects.find((project) => project.id === active.id);2511 const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);
1375 const overProject = sidebarProjects.find((project) => project.id === over.id);2512 const overProject = sidebarProjects.find((project) => project.projectKey === over.id);
1376 if (!activeProject || !overProject) return;2513 if (!activeProject || !overProject) return;
1377 reorderProjects(activeProject.id, overProject.id);2514 reorderProjects(activeProject.projectKey, overProject.projectKey);
1378 },2515 },
1379 [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects],2516 [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects],
1380 );2517 );
@@ -1412,148 +2549,117 @@ export default function Sidebar() { · 31 unmodified lines
1412 animatedThreadListsRef.current.add(node);2549 animatedThreadListsRef.current.add(node);
1413 }, []);2550 }, []);
1414 2551
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
1434 const visibleThreads = useMemo(2552 const visibleThreads = useMemo(
1435 () => sidebarThreads.filter((thread) => thread.archivedAt === null),2553 () => sidebarThreads.filter((thread) => thread.archivedAt === null),
1436 [sidebarThreads],2554 [sidebarThreads],
1437 );2555 );
1438 const sortedProjects = useMemo(2556 const sortedProjects = useMemo(() => {
1439 () =>2557 const sortableProjects = sidebarProjects.map((project) => ({
1440 sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder),2558 ...project,
1441 [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads],2559 id: project.projectKey,
1442 );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]);
1443 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";2576 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";
1444 const renderedProjects = useMemo(2577 const visibleSidebarThreadKeys = useMemo(
1445 () =>2578 () =>
1446 sortedProjects.map((project) => {2579 sortedProjects.flatMap((project) => {
1447 const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) =>
1448 resolveThreadStatusPill({
1449 thread: {
1450 ...thread,
1451 lastVisitedAt: threadLastVisitedAtById[thread.id],
1452 },
1453 });
1454 const projectThreads = sortThreadsForSidebar(2580 const projectThreads = sortThreadsForSidebar(
1455 (threadIdsByProjectId[project.id] ?? [])2581 (threadsByProjectKey.get(project.projectKey) ?? []).filter(
1456 .map((threadId) => sidebarThreadsById[threadId])2582 (thread) => thread.archivedAt === null,
1457 .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined)2583 ),
1458 .filter((thread) => thread.archivedAt === null),
1459 appSettings.sidebarThreadSortOrder,2584 appSettings.sidebarThreadSortOrder,
1460 );2585 );
1461 const projectStatus = resolveProjectStatusIndicator(2586 const projectExpanded = projectExpandedById[project.projectKey] ?? true;
1462 projectThreads.map((thread) => resolveProjectThreadStatus(thread)),2587 const activeThreadKey = routeThreadKey ?? undefined;
1463 );
1464 const activeThreadId = routeThreadId ?? undefined;
1465 const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
1466 const pinnedCollapsedThread =2588 const pinnedCollapsedThread =
1467 !project.expanded && activeThreadId2589 !projectExpanded && activeThreadKey
1468 ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null)2590 ? (projectThreads.find(
2591 (thread) =>
2592 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) ===
2593 activeThreadKey,
2594 ) ?? null)
1469 : null;2595 : null;
1470 const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null;2596 const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null;
1471 const {2597 if (!shouldShowThreadPanel) {
1472 hasHiddenThreads,2598 return [];
1473 hiddenThreads,2599 }
1474 visibleThreads: visibleProjectThreads,2600 const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey);
1475 } = getVisibleThreadsForProject({2601 const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;
1476 threads: projectThreads,2602 const previewThreads =
1477 activeThreadId,2603 isThreadListExpanded || !hasOverflowingThreads
1478 isThreadListExpanded,2604 ? projectThreads
1479 previewLimit: THREAD_PREVIEW_LIMIT,2605 : projectThreads.slice(0, THREAD_PREVIEW_LIMIT);
1480 });2606 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads;
1481 const hiddenThreadStatus = resolveProjectStatusIndicator(2607 return renderedThreads.map((thread) =>
1482 hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)),2608 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1483 );2609 );
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 };
1501 }),2610 }),
1502 [2611 [
1503 appSettings.sidebarThreadSortOrder,2612 appSettings.sidebarThreadSortOrder,
1504 expandedThreadListsByProject,2613 expandedThreadListsByProject,
1505 routeThreadId,2614 projectExpandedById,
2615 routeThreadKey,
1506 sortedProjects,2616 sortedProjects,
1507 sidebarThreadsById,2617 threadsByProjectKey,
1508 threadIdsByProjectId,
1509 threadLastVisitedAtById,
1510 ],2618 ],
1511 );2619 );
1512 const visibleSidebarThreadIds = useMemo(2620 const threadJumpCommandByKey = useMemo(() => {
1513 () => getVisibleSidebarThreadIds(renderedProjects),2621 const mapping = new Map<string, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>();
1514 [renderedProjects],2622 for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) {
1515 );
1516 const threadJumpCommandById = useMemo(() => {
1517 const mapping = new Map<ThreadId, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>();
1518 for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) {
1519 const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex);2623 const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex);
1520 if (!jumpCommand) {2624 if (!jumpCommand) {
1521 return mapping;2625 return mapping;
1522 }2626 }
1523 mapping.set(threadId, jumpCommand);2627 mapping.set(threadKey, jumpCommand);
1524 }2628 }
1525 2629
1526 return mapping;2630 return mapping;
1527 }, [visibleSidebarThreadIds]);2631 }, [visibleSidebarThreadKeys]);
1528 const threadJumpThreadIds = useMemo(2632 const threadJumpThreadKeys = useMemo(
1529 () => [...threadJumpCommandById.keys()],2633 () => [...threadJumpCommandByKey.keys()],
1530 [threadJumpCommandById],2634 [threadJumpCommandByKey],
1531 );2635 );
1532 const threadJumpLabelById = useMemo(() => {2636 const [threadJumpLabelByKey, setThreadJumpLabelByKey] =
1533 const mapping = new Map<ThreadId, string>();2637 useState<ReadonlyMap<string, string>>(EMPTY_THREAD_JUMP_LABELS);
1534 for (const [threadId, command] of threadJumpCommandById) {2638 const visibleThreadJumpLabelByKey = showThreadJumpHints
1535 const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions);2639 ? threadJumpLabelByKey
1536 if (label) {2640 : EMPTY_THREAD_JUMP_LABELS;
1537 mapping.set(threadId, label);2641 const orderedSidebarThreadKeys = visibleSidebarThreadKeys;
1538 }
1539 }
1540 return mapping;
1541 }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]);
1542 const orderedSidebarThreadIds = visibleSidebarThreadIds;
1543 2642
1544 useEffect(() => {2643 useEffect(() => {
1545 const getShortcutContext = () => ({
1546 terminalFocus: isTerminalFocused(),
1547 terminalOpen: routeTerminalOpen,
1548 });
1549
1550 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {2644 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
1551 updateThreadJumpHintsVisibility(2645 const shortcutContext = getCurrentSidebarShortcutContext();
1552 shouldShowThreadJumpHints(event, keybindings, {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,
1553 platform,2656 platform,
1554 context: getShortcutContext(),2657 terminalOpen: shortcutContext.terminalOpen,
1555 }),2658 threadJumpCommandByKey,
1556 );2659 });
2660 return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap;
2661 });
2662 updateThreadJumpHintsVisibility(shouldShowHints);
1557 2663
1558 if (event.defaultPrevented || event.repeat) {2664 if (event.defaultPrevented || event.repeat) {
1559 return;2665 return;
@@ -1561,22 +2667,26 @@ export default function Sidebar() { · 1 unmodified line
1561 2667
1562 const command = resolveShortcutCommand(event, keybindings, {2668 const command = resolveShortcutCommand(event, keybindings, {
1563 platform,2669 platform,
1564 context: getShortcutContext(),2670 context: shortcutContext,
1565 });2671 });
1566 const traversalDirection = threadTraversalDirectionFromCommand(command);2672 const traversalDirection = threadTraversalDirectionFromCommand(command);
1567 if (traversalDirection !== null) {2673 if (traversalDirection !== null) {
1568 const targetThreadId = resolveAdjacentThreadId({2674 const targetThreadKey = resolveAdjacentThreadId({
1569 threadIds: orderedSidebarThreadIds,2675 threadIds: orderedSidebarThreadKeys,
1570 currentThreadId: routeThreadId,2676 currentThreadId: routeThreadKey,
1571 direction: traversalDirection,2677 direction: traversalDirection,
1572 });2678 });
1573 if (!targetThreadId) {2679 if (!targetThreadKey) {
2680 return;
2681 }
2682 const targetThread = sidebarThreadByKey.get(targetThreadKey);
2683 if (!targetThread) {
1574 return;2684 return;
1575 }2685 }
1576 2686
1577 event.preventDefault();2687 event.preventDefault();
1578 event.stopPropagation();2688 event.stopPropagation();
1579 navigateToThread(targetThreadId);2689 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
1580 return;2690 return;
1581 }2691 }
1582 2692
@@ -1585,26 +2695,45 @@ export default function Sidebar() { · 2 unmodified lines
1585 return;2695 return;
1586 }2696 }
1587 2697
1588 const targetThreadId = threadJumpThreadIds[jumpIndex];2698 const targetThreadKey = threadJumpThreadKeys[jumpIndex];
1589 if (!targetThreadId) {2699 if (!targetThreadKey) {
2700 return;
2701 }
2702 const targetThread = sidebarThreadByKey.get(targetThreadKey);
2703 if (!targetThread) {
1590 return;2704 return;
1591 }2705 }
1592 2706
1593 event.preventDefault();2707 event.preventDefault();
1594 event.stopPropagation();2708 event.stopPropagation();
1595 navigateToThread(targetThreadId);2709 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
1596 };2710 };
1597 2711
1598 const onWindowKeyUp = (event: globalThis.KeyboardEvent) => {2712 const onWindowKeyUp = (event: globalThis.KeyboardEvent) => {
1599 updateThreadJumpHintsVisibility(2713 const shortcutContext = getCurrentSidebarShortcutContext();
1600 shouldShowThreadJumpHints(event, keybindings, {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,
1601 platform,2724 platform,
1602 context: getShortcutContext(),2725 terminalOpen: shortcutContext.terminalOpen,
1603 }),2726 threadJumpCommandByKey,
1604 );2727 });
2728 return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap;
2729 });
2730 updateThreadJumpHintsVisibility(shouldShowHints);
1605 };2731 };
1606 2732
1607 const onWindowBlur = () => {2733 const onWindowBlur = () => {
2734 setThreadJumpLabelByKey((current) =>
2735 current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS,
2736 );
1608 updateThreadJumpHintsVisibility(false);2737 updateThreadJumpHintsVisibility(false);
1609 };2738 };
1610 2739
@@ -1618,301 +2747,21 @@ export default function Sidebar() { · 7 unmodified lines
1618 window.removeEventListener("blur", onWindowBlur);2747 window.removeEventListener("blur", onWindowBlur);
1619 };2748 };
1620 }, [2749 }, [
2750 getCurrentSidebarShortcutContext,
1621 keybindings,2751 keybindings,
1622 navigateToThread,2752 navigateToThread,
1623 orderedSidebarThreadIds,2753 orderedSidebarThreadKeys,
1624 platform,2754 platform,
1625 routeTerminalOpen,2755 routeThreadKey,
1626 routeThreadId,2756 sidebarThreadByKey,
1627 threadJumpThreadIds,2757 threadJumpCommandByKey,
2758 threadJumpThreadKeys,
1628 updateThreadJumpHintsVisibility,2759 updateThreadJumpHintsVisibility,
1629 ]);2760 ]);
1630 2761
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
1913 useEffect(() => {2762 useEffect(() => {
1914 const onMouseDown = (event: globalThis.MouseEvent) => {2763 const onMouseDown = (event: globalThis.MouseEvent) => {
1915 if (selectedThreadIds.size === 0) return;2764 if (selectedThreadCount === 0) return;
1916 const target = event.target instanceof HTMLElement ? event.target : null;2765 const target = event.target instanceof HTMLElement ? event.target : null;
1917 if (!shouldClearThreadSelectionOnMouseDown(target)) return;2766 if (!shouldClearThreadSelectionOnMouseDown(target)) return;
1918 clearSelection();2767 clearSelection();
@@ -1922,7 +2771,7 @@ export default function Sidebar() { · 3 unmodified lines
1922 return () => {2771 return () => {
1923 window.removeEventListener("mousedown", onMouseDown);2772 window.removeEventListener("mousedown", onMouseDown);
1924 };2773 };
1925 }, [clearSelection, selectedThreadIds.size]);2774 }, [clearSelection, selectedThreadCount]);
1926 2775
1927 useEffect(() => {2776 useEffect(() => {
1928 if (!isElectron) return;2777 if (!isElectron) return;
@@ -1967,10 +2816,6 @@ export default function Sidebar() { · 38 unmodified lines
1967 desktopUpdateState && showArm64IntelBuildWarning2816 desktopUpdateState && showArm64IntelBuildWarning
1968 ? getArm64IntelBuildWarningDescription(desktopUpdateState)2817 ? getArm64IntelBuildWarningDescription(desktopUpdateState)
1969 : null;2818 : null;
1970 const newThreadShortcutLabel =
1971 shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ??
1972 shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions);
1973
1974 const handleDesktopUpdateButtonClick = useCallback(() => {2819 const handleDesktopUpdateButtonClick = useCallback(() => {
1975 const bridge = window.desktopBridge;2820 const bridge = window.desktopBridge;
1976 if (!bridge || !desktopUpdateState) return;2821 if (!bridge || !desktopUpdateState) return;
@@ -2033,246 +2878,82 @@ export default function Sidebar() { · 56 unmodified lines
2033 }2878 }
2034 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);2879 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);
2035 2880
2036 const expandThreadListForProject = useCallback((projectId: ProjectId) => {2881 const expandThreadListForProject = useCallback((projectKey: string) => {
2037 setExpandedThreadListsByProject((current) => {2882 setExpandedThreadListsByProject((current) => {
2038 if (current.has(projectId)) return current;2883 if (current.has(projectKey)) return current;
2039 const next = new Set(current);2884 const next = new Set(current);
2040 next.add(projectId);2885 next.add(projectKey);
2041 return next;2886 return next;
2042 });2887 });
2043 }, []);2888 }, []);
2044 2889
2045 const collapseThreadListForProject = useCallback((projectId: ProjectId) => {2890 const collapseThreadListForProject = useCallback((projectKey: string) => {
2046 setExpandedThreadListsByProject((current) => {2891 setExpandedThreadListsByProject((current) => {
2047 if (!current.has(projectId)) return current;2892 if (!current.has(projectKey)) return current;
2048 const next = new Set(current);2893 const next = new Set(current);
2049 next.delete(projectId);2894 next.delete(projectKey);
2050 return next;2895 return next;
2051 });2896 });
2052 }, []);2897 }, []);
2053 2898
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
2082 return (2899 return (
2083 <>2900 <>
2084 {isElectron ? (2901 <SidebarChromeHeader isElectron={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 )}
2093 2902
2094 {isOnSettings ? (2903 {isOnSettings ? (
2095 <SettingsSidebarNav pathname={pathname} />2904 <SettingsSidebarNav pathname={pathname} />
2096 ) : (2905 ) : (
2097 <>2906 <>
2098 <SidebarContent className="gap-0">2907 <SidebarProjectsContent
2099 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (2908 showArm64IntelBuildWarning={showArm64IntelBuildWarning}
2100 <SidebarGroup className="px-2 pt-2 pb-0">2909 arm64IntelBuildWarningDescription={arm64IntelBuildWarningDescription}
2101 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">2910 desktopUpdateButtonAction={desktopUpdateButtonAction}
2102 <TriangleAlertIcon />2911 desktopUpdateButtonDisabled={desktopUpdateButtonDisabled}
2103 <AlertTitle>Intel build on Apple Silicon</AlertTitle>2912 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
2104 <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>2913 projectSortOrder={appSettings.sidebarProjectSortOrder}
2105 {desktopUpdateButtonAction !== "none" ? (2914 threadSortOrder={appSettings.sidebarThreadSortOrder}
2106 <AlertAction>2915 updateSettings={updateSettings}
2107 <Button2916 shouldShowProjectPathEntry={shouldShowProjectPathEntry}
2108 size="xs"2917 handleStartAddProject={handleStartAddProject}
2109 variant="outline"2918 isElectron={isElectron}
2110 disabled={desktopUpdateButtonDisabled}2919 isPickingFolder={isPickingFolder}
2111 onClick={handleDesktopUpdateButtonClick}2920 isAddingProject={isAddingProject}
2112 >2921 handlePickFolder={handlePickFolder}
2113 {desktopUpdateButtonAction === "download"2922 addProjectInputRef={addProjectInputRef}
2114 ? "Download ARM build"2923 addProjectError={addProjectError}
2115 : "Install ARM build"}2924 newCwd={newCwd}
2116 </Button>2925 setNewCwd={setNewCwd}
2117 </AlertAction>2926 setAddProjectError={setAddProjectError}
2118 ) : null}2927 handleAddProject={handleAddProject}
2119 </Alert>2928 setAddingProject={setAddingProject}
2120 </SidebarGroup>2929 canAddProject={canAddProject}
2121 ) : null}2930 isManualProjectSorting={isManualProjectSorting}
2122 <SidebarGroup className="px-2 py-2">2931 projectDnDSensors={projectDnDSensors}
2123 <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">2932 projectCollisionDetection={projectCollisionDetection}
2124 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">2933 handleProjectDragStart={handleProjectDragStart}
2125 Projects2934 handleProjectDragEnd={handleProjectDragEnd}
2126 </span>2935 handleProjectDragCancel={handleProjectDragCancel}
2127 <div className="flex items-center gap-1">2936 handleNewThread={handleNewThread}
2128 <ProjectSortMenu2937 archiveThread={archiveThread}
2129 projectSortOrder={appSettings.sidebarProjectSortOrder}2938 deleteThread={deleteThread}
2130 threadSortOrder={appSettings.sidebarThreadSortOrder}2939 sortedProjects={sortedProjects}
2131 onProjectSortOrderChange={(sortOrder) => {2940 expandedThreadListsByProject={expandedThreadListsByProject}
2132 updateSettings({ sidebarProjectSortOrder: sortOrder });2941 activeRouteProjectKey={activeRouteProjectKey}
2133 }}2942 routeThreadKey={routeThreadKey}
2134 onThreadSortOrderChange={(sortOrder) => {2943 newThreadShortcutLabel={newThreadShortcutLabel}
2135 updateSettings({ sidebarThreadSortOrder: sortOrder });2944 threadJumpLabelByKey={visibleThreadJumpLabelByKey}
2136 }}2945 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2137 />2946 expandThreadListForProject={expandThreadListForProject}
2138 <Tooltip>2947 collapseThreadListForProject={collapseThreadListForProject}
2139 <TooltipTrigger2948 dragInProgressRef={dragInProgressRef}
2140 render={2949 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
2141 <button2950 suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
2142 type="button"2951 attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}
2143 aria-label={2952 projectsLength={projects.length}
2144 shouldShowProjectPathEntry ? "Cancel add project" : "Add project"2953 />
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>
2259 2954
2260 <SidebarSeparator />2955 <SidebarSeparator />
2261 <SidebarFooter className="p-2">2956 <SidebarChromeFooter />
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>
2276 </>2957 </>
2277 )}2958 )}
2278 </>2959 </>
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+247−0
@@ -0,0 +1,247 @@
1import "../index.css";
2
3import { scopeThreadRef } from "@t3tools/client-runtime";
4import { ThreadId } from "@t3tools/contracts";
5import { afterEach, describe, expect, it, vi } from "vitest";
6import { render } from "vitest-browser-react";
7
8const {
9 terminalConstructorSpy,
10 terminalDisposeSpy,
11 fitAddonFitSpy,
12 fitAddonLoadSpy,
13 environmentApiById,
14 readEnvironmentApiMock,
15 readLocalApiMock,
16} = vi.hoisted(() => ({
17 terminalConstructorSpy: vi.fn(),
18 terminalDisposeSpy: vi.fn(),
19 fitAddonFitSpy: vi.fn(),
20 fitAddonLoadSpy: vi.fn(),
21 environmentApiById: new Map<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
36vi.mock("@xterm/addon-fit", () => ({
37 FitAddon: class MockFitAddon {
38 fit = fitAddonFitSpy;
39 },
40}));
41
42vi.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
111vi.mock("~/environmentApi", () => ({
112 readEnvironmentApi: readEnvironmentApiMock,
113}));
114
115vi.mock("~/localApi", () => ({
116 readLocalApi: readLocalApiMock,
117}));
118
119import { TerminalViewport } from "./ThreadTerminalDrawer";
120
121const THREAD_ID = ThreadId.makeUnsafe("thread-terminal-browser");
122
123function 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
144async 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
192describe("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 @@
1import { FitAddon } from "@xterm/addon-fit";1import { FitAddon } from "@xterm/addon-fit";
2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";
3import {3import {
4 type ScopedThreadRef,
4 type TerminalEvent,5 type TerminalEvent,
5 type TerminalSessionSnapshot,6 type TerminalSessionSnapshot,
6 type ThreadId,7 type ThreadId,
@@ -31,7 +32,8 @@ import { · 24 unmodified lines
31 MAX_TERMINALS_PER_GROUP,32 MAX_TERMINALS_PER_GROUP,
32 type ThreadTerminalGroup,33 type ThreadTerminalGroup,
33} from "../types";34} from "../types";
34import { readNativeApi } from "~/nativeApi";35import { readEnvironmentApi } from "~/environmentApi";
36import { readLocalApi } from "~/localApi";
35import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore";37import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore";
36 38
37const MIN_DRAWER_HEIGHT = 180;39const MIN_DRAWER_HEIGHT = 180;
@@ -208,6 +210,7 @@ export function shouldHandleTerminalSelectionMouseUp( · 170 unmodified lines
208}210}
209 211
210interface TerminalViewportProps {212interface TerminalViewportProps {
213 threadRef: ScopedThreadRef;
211 threadId: ThreadId;214 threadId: ThreadId;
212 terminalId: string;215 terminalId: string;
213 terminalLabel: string;216 terminalLabel: string;
@@ -222,7 +225,8 @@ interface TerminalViewportProps { · 8 unmodified lines
222 drawerHeight: number;225 drawerHeight: number;
223}226}
224 227
225function TerminalViewport({228export function TerminalViewport({
229 threadRef,
226 threadId,230 threadId,
227 terminalId,231 terminalId,
228 terminalLabel,232 terminalLabel,
@@ -260,6 +264,9 @@ function TerminalViewport({ · 31 unmodified lines
260 if (!mount) return;264 if (!mount) return;
261 265
262 let disposed = false;266 let disposed = false;
267 const api = readEnvironmentApi(threadRef.environmentId);
268 const localApi = readLocalApi();
269 if (!api || !localApi) return;
263 270
264 const fitAddon = new FitAddon();271 const fitAddon = new FitAddon();
265 const terminal = new Terminal({272 const terminal = new Terminal({
@@ -277,9 +284,6 @@ function TerminalViewport({ · 11 unmodified lines
277 terminalRef.current = terminal;284 terminalRef.current = terminal;
278 fitAddonRef.current = fitAddon;285 fitAddonRef.current = fitAddon;
279 286
280 const api = readNativeApi();
281 if (!api) return;
282
283 const clearSelectionAction = () => {287 const clearSelectionAction = () => {
284 selectionActionRequestIdRef.current += 1;288 selectionActionRequestIdRef.current += 1;
285 if (selectionActionTimerRef.current !== null) {289 if (selectionActionTimerRef.current !== null) {
@@ -340,7 +344,7 @@ function TerminalViewport({ · 54 unmodified lines
340 const requestId = ++selectionActionRequestIdRef.current;344 const requestId = ++selectionActionRequestIdRef.current;
341 selectionActionOpenRef.current = true;345 selectionActionOpenRef.current = true;
342 try {346 try {
343 const clicked = await api.contextMenu.show(347 const clicked = await localApi.contextMenu.show(
344 [{ id: "add-to-chat", label: "Add to chat" }],348 [{ id: "add-to-chat", label: "Add to chat" }],
345 nextAction.position,349 nextAction.position,
346 );350 );
@@ -416,7 +420,7 @@ function TerminalViewport({ · 69 unmodified lines
416 if (!latestTerminal) return;420 if (!latestTerminal) return;
417 421
418 if (match.kind === "url") {422 if (match.kind === "url") {
419 void api.shell.openExternal(match.text).catch((error) => {423 void localApi.shell.openExternal(match.text).catch((error: unknown) => {
420 writeSystemMessage(424 writeSystemMessage(
421 latestTerminal,425 latestTerminal,
422 error instanceof Error ? error.message : "Unable to open link",426 error instanceof Error ? error.message : "Unable to open link",
@@ -426,7 +430,7 @@ function TerminalViewport({ · 3 unmodified lines
426 }430 }
427 431
428 const target = resolvePathLinkTarget(match.text, cwd);432 const target = resolvePathLinkTarget(match.text, cwd);
429 void openInPreferredEditor(api, target).catch((error) => {433 void openInPreferredEditor(localApi, target).catch((error) => {
430 writeSystemMessage(434 writeSystemMessage(
431 latestTerminal,435 latestTerminal,
432 error instanceof Error ? error.message : "Unable to open path",436 error instanceof Error ? error.message : "Unable to open path",
@@ -573,12 +577,12 @@ function TerminalViewport({ · 140 unmodified lines
573 const previousLastEntryId =577 const previousLastEntryId =
574 selectTerminalEventEntries(578 selectTerminalEventEntries(
575 previousState.terminalEventEntriesByKey,579 previousState.terminalEventEntriesByKey,
576 threadId,580 threadRef,
577 terminalId,581 terminalId,
578 ).at(-1)?.id ?? 0;582 ).at(-1)?.id ?? 0;
579 const nextEntries = selectTerminalEventEntries(583 const nextEntries = selectTerminalEventEntries(
580 state.terminalEventEntriesByKey,584 state.terminalEventEntriesByKey,
581 threadId,585 threadRef,
582 terminalId,586 terminalId,
583 );587 );
584 const nextLastEntryId = nextEntries.at(-1)?.id ?? 0;588 const nextLastEntryId = nextEntries.at(-1)?.id ?? 0;
@@ -608,7 +612,7 @@ function TerminalViewport({ · 23 unmodified lines
608 writeTerminalSnapshot(activeTerminal, snapshot);612 writeTerminalSnapshot(activeTerminal, snapshot);
609 const bufferedEntries = selectTerminalEventEntries(613 const bufferedEntries = selectTerminalEventEntries(
610 useTerminalStateStore.getState().terminalEventEntriesByKey,614 useTerminalStateStore.getState().terminalEventEntriesByKey,
611 threadId,615 threadRef,
612 terminalId,616 terminalId,
613 );617 );
614 const replayEntries = selectTerminalEventEntriesAfterSnapshot(618 const replayEntries = selectTerminalEventEntriesAfterSnapshot(
@@ -677,7 +681,7 @@ function TerminalViewport({ · 62 unmodified lines
677 // autoFocus is intentionally omitted;681 // autoFocus is intentionally omitted;
678 // it is only read at mount time and must not trigger terminal teardown/recreation.682 // it is only read at mount time and must not trigger terminal teardown/recreation.
679 // eslint-disable-next-line react-hooks/exhaustive-deps683 // eslint-disable-next-line react-hooks/exhaustive-deps
680 }, [cwd, runtimeEnv, terminalId, threadId]);684 }, [cwd, runtimeEnv, terminalId, threadId, threadRef]);
681 685
682 useEffect(() => {686 useEffect(() => {
683 if (!autoFocus) return;687 if (!autoFocus) return;
@@ -692,7 +696,7 @@ function TerminalViewport({ · 8 unmodified lines
692 }, [autoFocus, focusRequestId]);696 }, [autoFocus, focusRequestId]);
693 697
694 useEffect(() => {698 useEffect(() => {
695 const api = readNativeApi();699 const api = readEnvironmentApi(threadRef.environmentId);
696 const terminal = terminalRef.current;700 const terminal = terminalRef.current;
697 const fitAddon = fitAddonRef.current;701 const fitAddon = fitAddonRef.current;
698 if (!api || !terminal || !fitAddon) return;702 if (!api || !terminal || !fitAddon) return;
@@ -714,13 +718,14 @@ function TerminalViewport({ · 15 unmodified lines
714 return () => {718 return () => {
715 window.cancelAnimationFrame(frame);719 window.cancelAnimationFrame(frame);
716 };720 };
717 }, [drawerHeight, resizeEpoch, terminalId, threadId]);721 }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]);
718 return (722 return (
719 <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-[4px]" />723 <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-[4px]" />
720 );724 );
721}725}
722 726
723interface ThreadTerminalDrawerProps {727interface ThreadTerminalDrawerProps {
728 threadRef: ScopedThreadRef;
724 threadId: ThreadId;729 threadId: ThreadId;
725 cwd: string;730 cwd: string;
726 worktreePath?: string | null;731 worktreePath?: string | null;
@@ -773,6 +778,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA · 46 unmodified lines
773}778}
774 779
775export default function ThreadTerminalDrawer({780export default function ThreadTerminalDrawer({
781 threadRef,
776 threadId,782 threadId,
777 cwd,783 cwd,
778 worktreePath,784 worktreePath,
@@ -1098,6 +1104,7 @@ export default function ThreadTerminalDrawer({ · 319 unmodified lines
1098 >1104 >
1099 <div className="h-full p-1">1105 <div className="h-full p-1">
1100 <TerminalViewport1106 <TerminalViewport
1107 threadRef={threadRef}
1101 threadId={threadId}1108 threadId={threadId}
1102 terminalId={terminalId}1109 terminalId={terminalId}
1103 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}1110 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
@@ -1119,6 +1126,7 @@ export default function ThreadTerminalDrawer({ · 15 unmodified lines
1119 <div className="h-full p-1">1126 <div className="h-full p-1">
1120 <TerminalViewport1127 <TerminalViewport
1121 key={resolvedActiveTerminalId}1128 key={resolvedActiveTerminalId}
1129 threadRef={threadRef}
1122 threadId={threadId}1130 threadId={threadId}
1123 terminalId={resolvedActiveTerminalId}1131 terminalId={resolvedActiveTerminalId}
1124 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}1132 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
apps/web/src/components/chat/ChatHeader.tsx+10−1
@@ -1,9 +1,11 @@
1import {1import {
2 type EnvironmentId,
2 type EditorId,3 type EditorId,
3 type ProjectScript,4 type ProjectScript,
4 type ResolvedKeybindingsConfig,5 type ResolvedKeybindingsConfig,
5 type ThreadId,6 type ThreadId,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { scopeThreadRef } from "@t3tools/client-runtime";
7import { memo } from "react";9import { memo } from "react";
8import GitActionsControl from "../GitActionsControl";10import GitActionsControl from "../GitActionsControl";
9import { DiffIcon, TerminalSquareIcon } from "lucide-react";11import { DiffIcon, TerminalSquareIcon } from "lucide-react";
@@ -15,6 +17,7 @@ import { SidebarTrigger } from "../ui/sidebar"; · 5 unmodified lines
15import { OpenInPicker } from "./OpenInPicker";17import { OpenInPicker } from "./OpenInPicker";
16 18
17interface ChatHeaderProps {19interface ChatHeaderProps {
20 activeThreadEnvironmentId: EnvironmentId;
18 activeThreadId: ThreadId;21 activeThreadId: ThreadId;
19 activeThreadTitle: string;22 activeThreadTitle: string;
20 activeProjectName: string | undefined;23 activeProjectName: string | undefined;
@@ -39,6 +42,7 @@ interface ChatHeaderProps { · 18 unmodified lines
39}42}
40 43
41export const ChatHeader = memo(function ChatHeader({44export const ChatHeader = memo(function ChatHeader({
45 activeThreadEnvironmentId,
42 activeThreadId,46 activeThreadId,
43 activeThreadTitle,47 activeThreadTitle,
44 activeProjectName,48 activeProjectName,
@@ -101,7 +105,12 @@ export const ChatHeader = memo(function ChatHeader({ · 56 unmodified lines
101 openInCwd={openInCwd}105 openInCwd={openInCwd}
102 />106 />
103 )}107 )}
104 {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}108 {activeProjectName && (
109 <GitActionsControl
110 gitCwd={gitCwd}
111 activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)}
112 />
113 )}
105 <Tooltip>114 <Tooltip>
106 <TooltipTrigger115 <TooltipTrigger
107 render={116 render={
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+35−27
@@ -1,4 +1,10 @@
1import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts";1import {
2 DEFAULT_MODEL_BY_PROVIDER,
3 EnvironmentId,
4 ModelSelection,
5 ThreadId,
6} from "@t3tools/contracts";
7import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
2import "../../index.css";8import "../../index.css";
3 9
4import { page } from "vitest/browser";10import { page } from "vitest/browser";
@@ -9,35 +15,37 @@ import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; · 4 unmodified lines
9import { TraitsMenuContent } from "./TraitsPicker";15import { TraitsMenuContent } from "./TraitsPicker";
10import { useComposerDraftStore } from "../../composerDraftStore";16import { useComposerDraftStore } from "../../composerDraftStore";
11 17
18const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
19
12async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) {20async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) {
13 const threadId = ThreadId.makeUnsafe("thread-compact-menu");21 const threadId = ThreadId.makeUnsafe("thread-compact-menu");
22 const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);
23 const threadKey = scopedThreadKey(threadRef);
14 const provider = props?.modelSelection?.provider ?? "claudeAgent";24 const provider = props?.modelSelection?.provider ?? "claudeAgent";
15 const draftsByThreadId = {} as ReturnType<
16 typeof useComposerDraftStore.getState
17 >["draftsByThreadId"];
18 const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider];25 const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider];
19 26
20 draftsByThreadId[threadId] = {27 useComposerDraftStore.setState({
21 prompt: props?.prompt ?? "",28 draftsByThreadKey: {
22 images: [],29 [threadKey]: {
23 nonPersistedImageIds: [],30 prompt: props?.prompt ?? "",
24 persistedAttachments: [],31 images: [],
25 terminalContexts: [],32 nonPersistedImageIds: [],
26 modelSelectionByProvider: {33 persistedAttachments: [],
27 [provider]: {34 terminalContexts: [],
28 provider,35 modelSelectionByProvider: {
29 model,36 [provider]: {
30 ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}),37 provider,
38 model,
39 ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}),
40 },
41 },
42 activeProvider: provider,
43 runtimeMode: null,
44 interactionMode: null,
31 },45 },
32 },46 },
33 activeProvider: provider,47 draftThreadsByThreadKey: {},
34 runtimeMode: null,48 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
35 interactionMode: null,
36 };
37 useComposerDraftStore.setState({
38 draftsByThreadId,
39 draftThreadsByThreadId: {},
40 projectDraftThreadIdByProjectId: {},
41 });49 });
42 const host = document.createElement("div");50 const host = document.createElement("div");
43 document.body.append(host);51 document.body.append(host);
@@ -121,7 +129,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str · 77 unmodified lines
121 <TraitsMenuContent129 <TraitsMenuContent
122 provider={provider}130 provider={provider}
123 models={models}131 models={models}
124 threadId={threadId}132 threadRef={threadRef}
125 model={model}133 model={model}
126 prompt={props?.prompt ?? ""}134 prompt={props?.prompt ?? ""}
127 modelOptions={providerOptions}135 modelOptions={providerOptions}
@@ -150,9 +158,9 @@ describe("CompactComposerControlsMenu", () => { · 22 unmodified lines
150 afterEach(() => {158 afterEach(() => {
151 document.body.innerHTML = "";159 document.body.innerHTML = "";
152 useComposerDraftStore.setState({160 useComposerDraftStore.setState({
153 draftsByThreadId: {},161 draftsByThreadKey: {},
154 draftThreadsByThreadId: {},162 draftThreadsByThreadKey: {},
155 projectDraftThreadIdByProjectId: {},163 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
156 stickyModelSelectionByProvider: {},164 stickyModelSelectionByProvider: {},
157 });165 });
158 });166 });
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+20−18
@@ -1,5 +1,5 @@
1import { type ApprovalRequestId } from "@t3tools/contracts";1import { type ApprovalRequestId } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useRef } from "react";2import { memo, useEffect, useEffectEvent, useRef } from "react";
3import { type PendingUserInput } from "../../session-logic";3import { type PendingUserInput } from "../../session-logic";
4import {4import {
5 derivePendingUserInputProgress,5 derivePendingUserInputProgress,
@@ -60,6 +60,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 54 unmodified lines
60 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);60 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
61 const activeQuestion = progress.activeQuestion;61 const activeQuestion = progress.activeQuestion;
62 const autoAdvanceTimerRef = useRef<number | null>(null);62 const autoAdvanceTimerRef = useRef<number | null>(null);
63 const onAdvanceRef = useRef(onAdvance);
64
65 useEffect(() => {
66 onAdvanceRef.current = onAdvance;
67 }, [onAdvance]);
63 68
64 // Clear auto-advance timer on unmount69 // Clear auto-advance timer on unmount
65 useEffect(() => {70 useEffect(() => {
@@ -70,22 +75,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 4 unmodified lines
70 };75 };
71 }, []);76 }, []);
72 77
73 const handleOptionSelection = useCallback(78 const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => {
74 (questionId: string, optionLabel: string) => {79 onToggleOption(questionId, optionLabel);
75 onToggleOption(questionId, optionLabel);80 if (activeQuestion?.multiSelect) {
76 if (activeQuestion?.multiSelect) {81 return;
77 return;82 }
78 }83 if (autoAdvanceTimerRef.current !== null) {
79 if (autoAdvanceTimerRef.current !== null) {84 window.clearTimeout(autoAdvanceTimerRef.current);
80 window.clearTimeout(autoAdvanceTimerRef.current);85 }
81 }86 autoAdvanceTimerRef.current = window.setTimeout(() => {
82 autoAdvanceTimerRef.current = window.setTimeout(() => {87 autoAdvanceTimerRef.current = null;
83 autoAdvanceTimerRef.current = null;88 onAdvanceRef.current();
84 onAdvance();89 }, 200);
85 }, 200);90 });
86 },
87 [activeQuestion?.multiSelect, onAdvance, onToggleOption],
88 );
89 91
90 // Keyboard shortcut: number keys 1-9 select corresponding options when focus is92 // Keyboard shortcut: number keys 1-9 select corresponding options when focus is
91 // outside editable fields. Multi-select prompts toggle options in place; single-93 // outside editable fields. Multi-select prompts toggle options in place; single-
@@ -112,7 +114,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 20 unmodified lines
112 };114 };
113 document.addEventListener("keydown", handler);115 document.addEventListener("keydown", handler);
114 return () => document.removeEventListener("keydown", handler);116 return () => document.removeEventListener("keydown", handler);
115 }, [activeQuestion, handleOptionSelection, isResponding]);117 }, [activeQuestion, isResponding]);
116 118
117 if (!activeQuestion) {119 if (!activeQuestion) {
118 return null;120 return null;
apps/web/src/components/chat/MessagesTimeline.test.tsx+4−0
@@ -42,6 +42,8 @@ beforeAll(() => { · 41 unmodified lines
42 });42 });
43});43});
44 44
45const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never;
46
45describe("MessagesTimeline", () => {47describe("MessagesTimeline", () => {
46 it("renders inline terminal labels with the composer chip UI", async () => {48 it("renders inline terminal labels with the composer chip UI", async () => {
47 const { MessagesTimeline } = await import("./MessagesTimeline");49 const { MessagesTimeline } = await import("./MessagesTimeline");
@@ -85,6 +87,7 @@ describe("MessagesTimeline", () => { · 37 unmodified lines
85 onRevertUserMessage={() => {}}87 onRevertUserMessage={() => {}}
86 isRevertingCheckpoint={false}88 isRevertingCheckpoint={false}
87 onImageExpand={() => {}}89 onImageExpand={() => {}}
90 activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
88 markdownCwd={undefined}91 markdownCwd={undefined}
89 resolvedTheme="light"92 resolvedTheme="light"
90 timestampFormat="locale"93 timestampFormat="locale"
@@ -130,6 +133,7 @@ describe("MessagesTimeline", () => { · 39 unmodified lines
130 onRevertUserMessage={() => {}}133 onRevertUserMessage={() => {}}
131 isRevertingCheckpoint={false}134 isRevertingCheckpoint={false}
132 onImageExpand={() => {}}135 onImageExpand={() => {}}
136 activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
133 markdownCwd={undefined}137 markdownCwd={undefined}
134 resolvedTheme="light"138 resolvedTheme="light"
135 timestampFormat="locale"139 timestampFormat="locale"
apps/web/src/components/chat/MessagesTimeline.tsx+4−1
@@ -1,4 +1,4 @@
1import { type MessageId, type TurnId } from "@t3tools/contracts";1import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts";
2import {2import {
3 memo,3 memo,
4 useCallback,4 useCallback,
@@ -82,6 +82,7 @@ interface MessagesTimelineProps { · 77 unmodified lines
82 onRevertUserMessage: (messageId: MessageId) => void;82 onRevertUserMessage: (messageId: MessageId) => void;
83 isRevertingCheckpoint: boolean;83 isRevertingCheckpoint: boolean;
84 onImageExpand: (preview: ExpandedImagePreview) => void;84 onImageExpand: (preview: ExpandedImagePreview) => void;
85 activeThreadEnvironmentId: EnvironmentId;
85 markdownCwd: string | undefined;86 markdownCwd: string | undefined;
86 resolvedTheme: "light" | "dark";87 resolvedTheme: "light" | "dark";
87 timestampFormat: TimestampFormat;88 timestampFormat: TimestampFormat;
@@ -117,6 +118,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 29 unmodified lines
117 onRevertUserMessage,118 onRevertUserMessage,
118 isRevertingCheckpoint,119 isRevertingCheckpoint,
119 onImageExpand,120 onImageExpand,
121 activeThreadEnvironmentId,
120 markdownCwd,122 markdownCwd,
121 resolvedTheme,123 resolvedTheme,
122 timestampFormat,124 timestampFormat,
@@ -531,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 408 unmodified lines
531 <div className="min-w-0 px-1 py-0.5">533 <div className="min-w-0 px-1 py-0.5">
532 <ProposedPlanCard534 <ProposedPlanCard
533 planMarkdown={row.proposedPlan.planMarkdown}535 planMarkdown={row.proposedPlan.planMarkdown}
536 environmentId={activeThreadEnvironmentId}
534 cwd={markdownCwd}537 cwd={markdownCwd}
535 workspaceRoot={workspaceRoot}538 workspaceRoot={workspaceRoot}
536 />539 />
apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx+23−6
@@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = { · 18 unmodified lines
19 height: 1_100,19 height: 1_100,
20};20};
21const MARKDOWN_CWD = "/repo/project";21const MARKDOWN_CWD = "/repo/project";
22const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never;
22 23
23interface RowMeasurement {24interface RowMeasurement {
24 actualHeightPx: number;25 actualHeightPx: number;
@@ -31,7 +32,10 @@ interface RowMeasurement { · 6 unmodified lines
31interface VirtualizationScenario {32interface VirtualizationScenario {
32 name: string;33 name: string;
33 targetRowId: string;34 targetRowId: string;
34 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;35 props: Omit<
36 ComponentProps<typeof MessagesTimeline>,
37 "scrollContainer" | "activeThreadEnvironmentId"
38 >;
35 maxEstimateDeltaPx: number;39 maxEstimateDeltaPx: number;
36}40}
37 41
@@ -48,7 +52,10 @@ interface VirtualizerSnapshot { · 10 unmodified lines
48}52}
49 53
50function MessagesTimelineBrowserHarness(54function MessagesTimelineBrowserHarness(
51 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">,55 props: Omit<
56 ComponentProps<typeof MessagesTimeline>,
57 "scrollContainer" | "activeThreadEnvironmentId"
58 >,
52) {59) {
53 const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null);60 const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null);
54 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>(61 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>(
@@ -73,6 +80,7 @@ function MessagesTimelineBrowserHarness( · 18 unmodified lines
73 >80 >
74 <MessagesTimeline81 <MessagesTimeline
75 {...props}82 {...props}
83 activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
76 scrollContainer={scrollContainer}84 scrollContainer={scrollContainer}
77 expandedWorkGroups={expandedWorkGroups}85 expandedWorkGroups={expandedWorkGroups}
78 onToggleWorkGroup={handleToggleWorkGroup}86 onToggleWorkGroup={handleToggleWorkGroup}
@@ -143,7 +151,7 @@ function createBaseTimelineProps(input: { · 64 unmodified lines
143 completionDividerBeforeEntryId?: string | null;151 completionDividerBeforeEntryId?: string | null;
144 turnDiffSummaryByAssistantMessageId?: Map<MessageId, TurnDiffSummary>;152 turnDiffSummaryByAssistantMessageId?: Map<MessageId, TurnDiffSummary>;
145 onVirtualizerSnapshot?: ComponentProps<typeof MessagesTimeline>["onVirtualizerSnapshot"];153 onVirtualizerSnapshot?: ComponentProps<typeof MessagesTimeline>["onVirtualizerSnapshot"];
146}): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer"> {154}): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer" | "activeThreadEnvironmentId"> {
147 return {155 return {
148 hasMessages: true,156 hasMessages: true,
149 isWorking: false,157 isWorking: false,
@@ -481,7 +489,10 @@ async function waitForElement<T extends Element>( · 331 unmodified lines
481 489
482async function measureTimelineRow(input: {490async function measureTimelineRow(input: {
483 host: HTMLElement;491 host: HTMLElement;
484 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;492 props: Omit<
493 ComponentProps<typeof MessagesTimeline>,
494 "scrollContainer" | "activeThreadEnvironmentId"
495 >;
485 targetRowId: string;496 targetRowId: string;
486}): Promise<RowMeasurement> {497}): Promise<RowMeasurement> {
487 const scrollContainer = await waitForElement(498 const scrollContainer = await waitForElement(
@@ -550,7 +561,10 @@ async function measureTimelineRow(input: { · 62 unmodified lines
550}561}
551 562
552async function mountMessagesTimeline(input: {563async function mountMessagesTimeline(input: {
553 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;564 props: Omit<
565 ComponentProps<typeof MessagesTimeline>,
566 "scrollContainer" | "activeThreadEnvironmentId"
567 >;
554 viewport?: { width: number; height: number };568 viewport?: { width: number; height: number };
555}) {569}) {
556 const viewport = input.viewport ?? DEFAULT_VIEWPORT;570 const viewport = input.viewport ?? DEFAULT_VIEWPORT;
@@ -576,7 +590,10 @@ async function mountMessagesTimeline(input: { · 19 unmodified lines
576 return {590 return {
577 host,591 host,
578 rerender: async (592 rerender: async (
579 nextProps: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">,593 nextProps: Omit<
594 ComponentProps<typeof MessagesTimeline>,
595 "scrollContainer" | "activeThreadEnvironmentId"
596 >,
580 ) => {597 ) => {
581 await screen.rerender(<MessagesTimelineBrowserHarness {...nextProps} />);598 await screen.rerender(<MessagesTimelineBrowserHarness {...nextProps} />);
582 await waitForLayout();599 await waitForLayout();
apps/web/src/components/chat/OpenInPicker.tsx+3−3
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
18 Zed,18 Zed,
19} from "../Icons";19} from "../Icons";
20import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";20import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";
21import { readNativeApi } from "~/nativeApi";21import { readLocalApi } from "~/localApi";
22 22
23const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => {23const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => {
24 const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [24 const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [
@@ -93,7 +93,7 @@ export const OpenInPicker = memo(function OpenInPicker({ · 68 unmodified lines
93 93
94 const openInEditor = useCallback(94 const openInEditor = useCallback(
95 (editorId: EditorId | null) => {95 (editorId: EditorId | null) => {
96 const api = readNativeApi();96 const api = readLocalApi();
97 if (!api || !openInCwd) return;97 if (!api || !openInCwd) return;
98 const editor = editorId ?? preferredEditor;98 const editor = editorId ?? preferredEditor;
99 if (!editor) return;99 if (!editor) return;
@@ -110,7 +110,7 @@ export const OpenInPicker = memo(function OpenInPicker({ · 10 unmodified lines
110 110
111 useEffect(() => {111 useEffect(() => {
112 const handler = (e: globalThis.KeyboardEvent) => {112 const handler = (e: globalThis.KeyboardEvent) => {
113 const api = readNativeApi();113 const api = readLocalApi();
114 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;114 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
115 if (!api || !openInCwd) return;115 if (!api || !openInCwd) return;
116 if (!preferredEditor) return;116 if (!preferredEditor) return;
apps/web/src/components/chat/ProposedPlanCard.tsx+5−2
@@ -1,4 +1,5 @@
1import { memo, useState, useId } from "react";1import { memo, useState, useId } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";
2import {3import {
3 buildCollapsedProposedPlanPreviewMarkdown,4 buildCollapsedProposedPlanPreviewMarkdown,
4 buildProposedPlanMarkdownFilename,5 buildProposedPlanMarkdownFilename,
@@ -24,15 +25,17 @@ import { · 19 unmodified lines
24 DialogTitle,25 DialogTitle,
25} from "../ui/dialog";26} from "../ui/dialog";
26import { toastManager } from "../ui/toast";27import { toastManager } from "../ui/toast";
27import { readNativeApi } from "~/nativeApi";28import { readEnvironmentApi } from "~/environmentApi";
28import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";29import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
29 30
30export const ProposedPlanCard = memo(function ProposedPlanCard({31export const ProposedPlanCard = memo(function ProposedPlanCard({
31 planMarkdown,32 planMarkdown,
33 environmentId,
32 cwd,34 cwd,
33 workspaceRoot,35 workspaceRoot,
34}: {36}: {
35 planMarkdown: string;37 planMarkdown: string;
38 environmentId: EnvironmentId;
36 cwd: string | undefined;39 cwd: string | undefined;
37 workspaceRoot: string | undefined;40 workspaceRoot: string | undefined;
38}) {41}) {
@@ -82,7 +85,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ · 43 unmodified lines
82 };85 };
83 86
84 const handleSaveToWorkspace = () => {87 const handleSaveToWorkspace = () => {
85 const api = readNativeApi();88 const api = readEnvironmentApi(environmentId);
86 const relativePath = savePath.trim();89 const relativePath = savePath.trim();
87 if (!api || !workspaceRoot) {90 if (!api || !workspaceRoot) {
88 return;91 return;
apps/web/src/components/chat/TraitsPicker.browser.tsx+30−24
@@ -6,10 +6,11 @@ import { · 5 unmodified lines
6 CodexModelOptions,6 CodexModelOptions,
7 DEFAULT_MODEL_BY_PROVIDER,7 DEFAULT_MODEL_BY_PROVIDER,
8 DEFAULT_SERVER_SETTINGS,8 DEFAULT_SERVER_SETTINGS,
9 ProjectId,9 EnvironmentId,
10 type ServerProvider,10 type ServerProvider,
11 ThreadId,11 ThreadId,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
13import { page } from "vitest/browser";14import { page } from "vitest/browser";
14import { useCallback } from "react";15import { useCallback } from "react";
15import { afterEach, describe, expect, it, vi } from "vitest";16import { afterEach, describe, expect, it, vi } from "vitest";
@@ -27,7 +28,13 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; · 11 unmodified lines
27 28
28// ── Claude TraitsPicker tests ─────────────────────────────────────────29// ── Claude TraitsPicker tests ─────────────────────────────────────────
29 30
31const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
30const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits");32const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits");
33const CLAUDE_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CLAUDE_THREAD_ID);
34const CLAUDE_THREAD_KEY = scopedThreadKey(CLAUDE_THREAD_REF);
35const CODEX_THREAD_ID = ThreadId.makeUnsafe("thread-codex-traits");
36const CODEX_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CODEX_THREAD_ID);
37const CODEX_THREAD_KEY = scopedThreadKey(CODEX_THREAD_REF);
31const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [38const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
32 {39 {
33 provider: "codex",40 provider: "codex",
@@ -120,10 +127,10 @@ function ClaudeTraitsPickerHarness(props: { · 86 unmodified lines
120 fallbackModelSelection: ModelSelection | null;127 fallbackModelSelection: ModelSelection | null;
121 triggerVariant?: "ghost" | "outline";128 triggerVariant?: "ghost" | "outline";
122}) {129}) {
123 const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt;130 const prompt = useComposerThreadDraft(CLAUDE_THREAD_REF).prompt;
124 const setPrompt = useComposerDraftStore((store) => store.setPrompt);131 const setPrompt = useComposerDraftStore((store) => store.setPrompt);
125 const { modelOptions, selectedModel } = useEffectiveComposerModelState({132 const { modelOptions, selectedModel } = useEffectiveComposerModelState({
126 threadId: CLAUDE_THREAD_ID,133 threadRef: CLAUDE_THREAD_REF,
127 providers: TEST_PROVIDERS,134 providers: TEST_PROVIDERS,
128 selectedProvider: "claudeAgent",135 selectedProvider: "claudeAgent",
129 threadModelSelection: props.fallbackModelSelection,136 threadModelSelection: props.fallbackModelSelection,
@@ -135,7 +142,7 @@ function ClaudeTraitsPickerHarness(props: { · 5 unmodified lines
135 });142 });
136 const handlePromptChange = useCallback(143 const handlePromptChange = useCallback(
137 (nextPrompt: string) => {144 (nextPrompt: string) => {
138 setPrompt(CLAUDE_THREAD_ID, nextPrompt);145 setPrompt(CLAUDE_THREAD_REF, nextPrompt);
139 },146 },
140 [setPrompt],147 [setPrompt],
141 );148 );
@@ -144,7 +151,7 @@ function ClaudeTraitsPickerHarness(props: { · 2 unmodified lines
144 <TraitsPicker151 <TraitsPicker
145 provider="claudeAgent"152 provider="claudeAgent"
146 models={TEST_PROVIDERS[1]!.models}153 models={TEST_PROVIDERS[1]!.models}
147 threadId={CLAUDE_THREAD_ID}154 threadRef={CLAUDE_THREAD_REF}
148 model={selectedModel ?? props.model}155 model={selectedModel ?? props.model}
149 prompt={prompt}156 prompt={prompt}
150 modelOptions={modelOptions?.claudeAgent}157 modelOptions={modelOptions?.claudeAgent}
@@ -168,8 +175,8 @@ async function mountClaudePicker(props?: { · 17 unmodified lines
168}) {175}) {
169 const model = props?.model ?? "claude-opus-4-6";176 const model = props?.model ?? "claude-opus-4-6";
170 const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined;177 const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined;
171 const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = {178 const draftsByThreadKey: Record<string, ComposerThreadDraftState> = {
172 [CLAUDE_THREAD_ID]: {179 [CLAUDE_THREAD_KEY]: {
173 prompt: props?.prompt ?? "",180 prompt: props?.prompt ?? "",
174 images: [],181 images: [],
175 nonPersistedImageIds: [],182 nonPersistedImageIds: [],
@@ -192,9 +199,9 @@ async function mountClaudePicker(props?: { · 16 unmodified lines
192 },199 },
193 };200 };
194 useComposerDraftStore.setState({201 useComposerDraftStore.setState({
195 draftsByThreadId,202 draftsByThreadKey,
196 draftThreadsByThreadId: {},203 draftThreadsByThreadKey: {},
197 projectDraftThreadIdByProjectId: {},204 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
198 });205 });
199 const host = document.createElement("div");206 const host = document.createElement("div");
200 document.body.append(host);207 document.body.append(host);
@@ -230,9 +237,9 @@ describe("TraitsPicker (Claude)", () => { · 29 unmodified lines
230 afterEach(() => {237 afterEach(() => {
231 document.body.innerHTML = "";238 document.body.innerHTML = "";
232 useComposerDraftStore.setState({239 useComposerDraftStore.setState({
233 draftsByThreadId: {},240 draftsByThreadKey: {},
234 draftThreadsByThreadId: {},241 draftThreadsByThreadKey: {},
235 projectDraftThreadIdByProjectId: {},242 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
236 stickyModelSelectionByProvider: {},243 stickyModelSelectionByProvider: {},
237 });244 });
238 });245 });
@@ -369,10 +376,9 @@ describe("TraitsPicker (Claude)", () => { · 130 unmodified lines
369// ── Codex TraitsPicker tests ──────────────────────────────────────────376// ── Codex TraitsPicker tests ──────────────────────────────────────────
370 377
371async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) {378async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) {
372 const threadId = ThreadId.makeUnsafe("thread-codex-traits");
373 const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex;379 const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex;
374 const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = {380 const draftsByThreadKey: Record<string, ComposerThreadDraftState> = {
375 [threadId]: {381 [CODEX_THREAD_KEY]: {
376 prompt: "",382 prompt: "",
377 images: [],383 images: [],
378 nonPersistedImageIds: [],384 nonPersistedImageIds: [],
@@ -392,10 +398,10 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt · 13 unmodified lines
392 };398 };
393 399
394 useComposerDraftStore.setState({400 useComposerDraftStore.setState({
395 draftsByThreadId,401 draftsByThreadKey,
396 draftThreadsByThreadId: {},402 draftThreadsByThreadKey: {},
397 projectDraftThreadIdByProjectId: {403 logicalProjectDraftThreadKeyByLogicalProjectKey: {
398 [ProjectId.makeUnsafe("project-codex-traits")]: threadId,404 "environment-local:project-codex-traits": CODEX_THREAD_KEY,
399 },405 },
400 });406 });
401 const host = document.createElement("div");407 const host = document.createElement("div");
@@ -404,7 +410,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt · 2 unmodified lines
404 <TraitsPicker410 <TraitsPicker
405 provider="codex"411 provider="codex"
406 models={TEST_PROVIDERS[0]!.models}412 models={TEST_PROVIDERS[0]!.models}
407 threadId={threadId}413 threadRef={CODEX_THREAD_REF}
408 model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex}414 model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex}
409 prompt=""415 prompt=""
410 modelOptions={props.options}416 modelOptions={props.options}
@@ -429,9 +435,9 @@ describe("TraitsPicker (Codex)", () => { · 18 unmodified lines
429 document.body.innerHTML = "";435 document.body.innerHTML = "";
430 localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY);436 localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY);
431 useComposerDraftStore.setState({437 useComposerDraftStore.setState({
432 draftsByThreadId: {},438 draftsByThreadKey: {},
433 draftThreadsByThreadId: {},439 draftThreadsByThreadKey: {},
434 projectDraftThreadIdByProjectId: {},440 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
435 stickyModelSelectionByProvider: {},441 stickyModelSelectionByProvider: {},
436 });442 });
437 });443 });
apps/web/src/components/chat/TraitsPicker.tsx+12−5
@@ -3,8 +3,8 @@ import { · 2 unmodified lines
3 type CodexModelOptions,3 type CodexModelOptions,
4 type ProviderKind,4 type ProviderKind,
5 type ProviderModelOptions,5 type ProviderModelOptions,
6 type ScopedThreadRef,
6 type ServerProviderModel,7 type ServerProviderModel,
7 type ThreadId,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import {9import {
10 applyClaudePromptEffortPrefix,10 applyClaudePromptEffortPrefix,
@@ -28,18 +28,19 @@ import { · 17 unmodified lines
28 MenuSeparator as MenuDivider,28 MenuSeparator as MenuDivider,
29 MenuTrigger,29 MenuTrigger,
30} from "../ui/menu";30} from "../ui/menu";
31import { useComposerDraftStore } from "../../composerDraftStore";31import { useComposerDraftStore, DraftId } from "../../composerDraftStore";
32import { getProviderModelCapabilities } from "../../providerModels";32import { getProviderModelCapabilities } from "../../providerModels";
33import { cn } from "~/lib/utils";33import { cn } from "~/lib/utils";
34 34
35type ProviderOptions = ProviderModelOptions[ProviderKind];35type ProviderOptions = ProviderModelOptions[ProviderKind];
36type TraitsPersistence =36type TraitsPersistence =
37 | {37 | {
38 threadId: ThreadId;38 threadRef?: ScopedThreadRef;
39 draftId?: DraftId;
39 onModelOptionsChange?: never;40 onModelOptionsChange?: never;
40 }41 }
41 | {42 | {
42 threadId?: undefined;43 threadRef?: undefined;
43 onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void;44 onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void;
44 };45 };
45 46
@@ -167,7 +168,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 121 unmodified lines
167 persistence.onModelOptionsChange(nextOptions);168 persistence.onModelOptionsChange(nextOptions);
168 return;169 return;
169 }170 }
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 });
171 },178 },
172 [persistence, provider, setProviderModelOptions],179 [persistence, provider, setProviderModelOptions],
173 );180 );
apps/web/src/components/chat/composerProviderRegistry.test.tsx+33−1
@@ -1,6 +1,10 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import type { ServerProviderModel } from "@t3tools/contracts";2import type { ServerProviderModel } from "@t3tools/contracts";
3import { getComposerProviderState } from "./composerProviderRegistry";3import {
4 getComposerProviderState,
5 renderProviderTraitsMenuContent,
6 renderProviderTraitsPicker,
7} from "./composerProviderRegistry";
4 8
5const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [9const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [
6 {10 {
@@ -417,3 +421,31 @@ describe("getComposerProviderState", () => { · 410 unmodified lines
417 expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode");421 expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode");
418 });422 });
419});423});
424
425describe("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 @@
1import {1import {
2 type ProviderKind,2 type ProviderKind,
3 type ProviderModelOptions,3 type ProviderModelOptions,
4 type ScopedThreadRef,
4 type ServerProviderModel,5 type ServerProviderModel,
5 type ThreadId,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model";7import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model";
8import type { ReactNode } from "react";8import type { ReactNode } from "react";
9import type { DraftId } from "../../composerDraftStore";
9import { getProviderModelCapabilities } from "../../providerModels";10import { getProviderModelCapabilities } from "../../providerModels";
10import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";11import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
11import {12import {
@@ -33,7 +34,8 @@ export type ComposerProviderState = { · 21 unmodified lines
33type ProviderRegistryEntry = {34type ProviderRegistryEntry = {
34 getState: (input: ComposerProviderStateInput) => ComposerProviderState;35 getState: (input: ComposerProviderStateInput) => ComposerProviderState;
35 renderTraitsMenuContent: (input: {36 renderTraitsMenuContent: (input: {
36 threadId: ThreadId;37 threadRef?: ScopedThreadRef;
38 draftId?: DraftId;
37 model: string;39 model: string;
38 models: ReadonlyArray<ServerProviderModel>;40 models: ReadonlyArray<ServerProviderModel>;
39 modelOptions: ProviderModelOptions[ProviderKind] | undefined;41 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
@@ -41,7 +43,8 @@ type ProviderRegistryEntry = { · 1 unmodified line
41 onPromptChange: (prompt: string) => void;43 onPromptChange: (prompt: string) => void;
42 }) => ReactNode;44 }) => ReactNode;
43 renderTraitsPicker: (input: {45 renderTraitsPicker: (input: {
44 threadId: ThreadId;46 threadRef?: ScopedThreadRef;
47 draftId?: DraftId;
45 model: string;48 model: string;
46 models: ReadonlyArray<ServerProviderModel>;49 models: ReadonlyArray<ServerProviderModel>;
47 modelOptions: ProviderModelOptions[ProviderKind] | undefined;50 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
@@ -50,6 +53,13 @@ type ProviderRegistryEntry = { · 2 unmodified lines
50 }) => ReactNode;53 }) => ReactNode;
51};54};
52 55
56function hasComposerTraitsTarget(input: {
57 threadRef: ScopedThreadRef | undefined;
58 draftId: DraftId | undefined;
59}): boolean {
60 return input.threadRef !== undefined || input.draftId !== undefined;
61}
62
53function getProviderStateFromCapabilities(63function getProviderStateFromCapabilities(
54 input: ComposerProviderStateInput,64 input: ComposerProviderStateInput,
55): ComposerProviderState {65): ComposerProviderState {
@@ -94,66 +104,92 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 38 unmodified lines
94 codex: {104 codex: {
95 getState: (input) => getProviderStateFromCapabilities(input),105 getState: (input) => getProviderStateFromCapabilities(input),
96 renderTraitsMenuContent: ({106 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,
98 model,130 model,
99 models,131 models,
100 modelOptions,132 modelOptions,
101 prompt,133 prompt,
102 onPromptChange,134 onPromptChange,
103 }) => (135 }) =>
104 <TraitsMenuContent136 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
105 provider="codex"137 <TraitsPicker
106 models={models}138 provider="codex"
107 threadId={threadId}139 models={models}
108 model={model}140 {...(threadRef ? { threadRef } : {})}
109 modelOptions={modelOptions}141 {...(draftId ? { draftId } : {})}
110 prompt={prompt}142 model={model}
111 onPromptChange={onPromptChange}143 modelOptions={modelOptions}
112 />144 prompt={prompt}
113 ),145 onPromptChange={onPromptChange}
114 renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => (146 />
115 <TraitsPicker147 ),
116 provider="codex"
117 models={models}
118 threadId={threadId}
119 model={model}
120 modelOptions={modelOptions}
121 prompt={prompt}
122 onPromptChange={onPromptChange}
123 />
124 ),
125 },148 },
126 claudeAgent: {149 claudeAgent: {
127 getState: (input) => getProviderStateFromCapabilities(input),150 getState: (input) => getProviderStateFromCapabilities(input),
128 renderTraitsMenuContent: ({151 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,
130 model,175 model,
131 models,176 models,
132 modelOptions,177 modelOptions,
133 prompt,178 prompt,
134 onPromptChange,179 onPromptChange,
135 }) => (180 }) =>
136 <TraitsMenuContent181 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
137 provider="claudeAgent"182 <TraitsPicker
138 models={models}183 provider="claudeAgent"
139 threadId={threadId}184 models={models}
140 model={model}185 {...(threadRef ? { threadRef } : {})}
141 modelOptions={modelOptions}186 {...(draftId ? { draftId } : {})}
142 prompt={prompt}187 model={model}
143 onPromptChange={onPromptChange}188 modelOptions={modelOptions}
144 />189 prompt={prompt}
145 ),190 onPromptChange={onPromptChange}
146 renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => (191 />
147 <TraitsPicker192 ),
148 provider="claudeAgent"
149 models={models}
150 threadId={threadId}
151 model={model}
152 modelOptions={modelOptions}
153 prompt={prompt}
154 onPromptChange={onPromptChange}
155 />
156 ),
157 },193 },
158};194};
159 195
@@ -163,7 +199,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com · 3 unmodified lines
163 199
164export function renderProviderTraitsMenuContent(input: {200export function renderProviderTraitsMenuContent(input: {
165 provider: ProviderKind;201 provider: ProviderKind;
166 threadId: ThreadId;202 threadRef?: ScopedThreadRef;
203 draftId?: DraftId;
167 model: string;204 model: string;
168 models: ReadonlyArray<ServerProviderModel>;205 models: ReadonlyArray<ServerProviderModel>;
169 modelOptions: ProviderModelOptions[ProviderKind] | undefined;206 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
@@ -171,7 +208,8 @@ export function renderProviderTraitsMenuContent(input: { · 1 unmodified line
171 onPromptChange: (prompt: string) => void;208 onPromptChange: (prompt: string) => void;
172}): ReactNode {209}): ReactNode {
173 return composerProviderRegistry[input.provider].renderTraitsMenuContent({210 return composerProviderRegistry[input.provider].renderTraitsMenuContent({
174 threadId: input.threadId,211 ...(input.threadRef ? { threadRef: input.threadRef } : {}),
212 ...(input.draftId ? { draftId: input.draftId } : {}),
175 model: input.model,213 model: input.model,
176 models: input.models,214 models: input.models,
177 modelOptions: input.modelOptions,215 modelOptions: input.modelOptions,
@@ -182,7 +220,8 @@ export function renderProviderTraitsMenuContent(input: { · 4 unmodified lines
182 220
183export function renderProviderTraitsPicker(input: {221export function renderProviderTraitsPicker(input: {
184 provider: ProviderKind;222 provider: ProviderKind;
185 threadId: ThreadId;223 threadRef?: ScopedThreadRef;
224 draftId?: DraftId;
186 model: string;225 model: string;
187 models: ReadonlyArray<ServerProviderModel>;226 models: ReadonlyArray<ServerProviderModel>;
188 modelOptions: ProviderModelOptions[ProviderKind] | undefined;227 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
@@ -190,7 +229,8 @@ export function renderProviderTraitsPicker(input: { · 1 unmodified line
190 onPromptChange: (prompt: string) => void;229 onPromptChange: (prompt: string) => void;
191}): ReactNode {230}): ReactNode {
192 return composerProviderRegistry[input.provider].renderTraitsPicker({231 return composerProviderRegistry[input.provider].renderTraitsPicker({
193 threadId: input.threadId,232 ...(input.threadRef ? { threadRef: input.threadRef } : {}),
233 ...(input.draftId ? { draftId: input.draftId } : {}),
194 model: input.model,234 model: input.model,
195 models: input.models,235 models: input.models,
196 modelOptions: input.modelOptions,236 modelOptions: input.modelOptions,
apps/web/src/components/settings/SettingsPanels.browser.tsx+18−6
@@ -1,17 +1,29 @@
1import "../../index.css";1import "../../index.css";
2 2
3import { DEFAULT_SERVER_SETTINGS, type NativeApi, type ServerConfig } from "@t3tools/contracts";3import {
4 DEFAULT_SERVER_SETTINGS,
5 EnvironmentId,
6 type LocalApi,
7 type ServerConfig,
8} from "@t3tools/contracts";
4import { page } from "vitest/browser";9import { page } from "vitest/browser";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";10import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6import { render } from "vitest-browser-react";11import { render } from "vitest-browser-react";
7 12
8import { __resetNativeApiForTests } from "../../nativeApi";13import { __resetLocalApiForTests } from "../../localApi";
9import { AppAtomRegistryProvider } from "../../rpc/atomRegistry";14import { AppAtomRegistryProvider } from "../../rpc/atomRegistry";
10import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";15import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
11import { GeneralSettingsPanel } from "./SettingsPanels";16import { GeneralSettingsPanel } from "./SettingsPanels";
12 17
13function createBaseServerConfig(): ServerConfig {18function createBaseServerConfig(): ServerConfig {
14 return {19 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 },
15 cwd: "/repo/project",27 cwd: "/repo/project",
16 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",28 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
17 keybindings: [],29 keybindings: [],
@@ -32,14 +44,14 @@ function createBaseServerConfig(): ServerConfig { · 14 unmodified lines
32describe("GeneralSettingsPanel observability", () => {44describe("GeneralSettingsPanel observability", () => {
33 beforeEach(async () => {45 beforeEach(async () => {
34 resetServerStateForTests();46 resetServerStateForTests();
35 await __resetNativeApiForTests();47 await __resetLocalApiForTests();
36 localStorage.clear();48 localStorage.clear();
37 document.body.innerHTML = "";49 document.body.innerHTML = "";
38 });50 });
39 51
40 afterEach(async () => {52 afterEach(async () => {
41 resetServerStateForTests();53 resetServerStateForTests();
42 await __resetNativeApiForTests();54 await __resetLocalApiForTests();
43 document.body.innerHTML = "";55 document.body.innerHTML = "";
44 });56 });
45 57
@@ -68,12 +80,12 @@ describe("GeneralSettingsPanel observability", () => { · 22 unmodified lines
68 });80 });
69 81
70 it("opens the logs folder in the preferred editor", async () => {82 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);
72 window.nativeApi = {84 window.nativeApi = {
73 shell: {85 shell: {
74 openInEditor,86 openInEditor,
75 },87 },
76 } as unknown as NativeApi;88 } as unknown as LocalApi;
77 89
78 setServerConfigSnapshot(createBaseServerConfig());90 setServerConfigSnapshot(createBaseServerConfig());
79 91
apps/web/src/components/settings/SettingsPanels.tsx+36−35
@@ -13,11 +13,12 @@ import { useQueryClient } from "@tanstack/react-query"; · 12 unmodified lines
13import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";13import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
14import {14import {
15 PROVIDER_DISPLAY_NAMES,15 PROVIDER_DISPLAY_NAMES,
16 type ScopedThreadRef,
16 type ProviderKind,17 type ProviderKind,
17 type ServerProvider,18 type ServerProvider,
18 type ServerProviderModel,19 type ServerProviderModel,
19 ThreadId,
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { scopeThreadRef } from "@t3tools/client-runtime";
21import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";22import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
22import { normalizeModelSlug } from "@t3tools/shared/model";23import { normalizeModelSlug } from "@t3tools/shared/model";
23import { Equal } from "effect";24import { Equal } from "effect";
@@ -45,8 +46,12 @@ import { · 21 unmodified lines
45 getCustomModelOptionsByProvider,46 getCustomModelOptionsByProvider,
46 resolveAppModelSelectionState,47 resolveAppModelSelectionState,
47} from "../../modelSelection";48} from "../../modelSelection";
48import { ensureNativeApi, readNativeApi } from "../../nativeApi";49import { ensureLocalApi, readLocalApi } from "../../localApi";
49import { useStore } from "../../store";50import {
51 selectProjectsAcrossEnvironments,
52 selectThreadsAcrossEnvironments,
53 useStore,
54} from "../../store";
50import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";55import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
51import { cn } from "../../lib/utils";56import { cn } from "../../lib/utils";
52import { Button } from "../ui/button";57import { Button } from "../ui/button";
@@ -496,8 +501,8 @@ export function useSettingsRestore(onRestored?: () => void) { · 443 unmodified lines
496 501
497 const restoreDefaults = useCallback(async () => {502 const restoreDefaults = useCallback(async () => {
498 if (changedSettingLabels.length === 0) return;503 if (changedSettingLabels.length === 0) return;
499 const api = readNativeApi();504 const api = readLocalApi();
500 const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm(505 const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm(
501 ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(506 ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(
502 "\n",507 "\n",
503 ),508 ),
@@ -554,7 +559,7 @@ export function GeneralSettingsPanel() { · 50 unmodified lines
554 if (refreshingRef.current) return;559 if (refreshingRef.current) return;
555 refreshingRef.current = true;560 refreshingRef.current = true;
556 setIsRefreshingProviders(true);561 setIsRefreshingProviders(true);
557 void ensureNativeApi()562 void ensureLocalApi()
558 .server.refreshProviders()563 .server.refreshProviders()
559 .catch((error: unknown) => {564 .catch((error: unknown) => {
560 console.warn("Failed to refresh providers", error);565 console.warn("Failed to refresh providers", error);
@@ -614,7 +619,7 @@ export function GeneralSettingsPanel() { · 53 unmodified lines
614 return;619 return;
615 }620 }
616 621
617 void ensureNativeApi()622 void ensureLocalApi()
618 .shell.openInEditor(path, editor)623 .shell.openInEditor(path, editor)
619 .catch((error) => {624 .catch((error) => {
620 setOpenPathErrorByTarget((existing) => ({625 setOpenPathErrorByTarget((existing) => ({
@@ -1479,20 +1484,10 @@ export function GeneralSettingsPanel() { · 858 unmodified lines
1479}1484}
1480 1485
1481export function ArchivedThreadsPanel() {1486export function ArchivedThreadsPanel() {
1482 const projectIds = useStore((store) => store.projectIds);1487 const projects = useStore(selectProjectsAcrossEnvironments);
1483 const projectById = useStore((store) => store.projectById);1488 const threads = useStore(selectThreadsAcrossEnvironments);
1484 const threadIds = useStore((store) => store.threadIds);
1485 const threadShellById = useStore((store) => store.threadShellById);
1486 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();1489 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
1487 const archivedGroups = useMemo(() => {1490 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 });
1496 return projects1491 return projects
1497 .map((project) => ({1492 .map((project) => ({
1498 project,1493 project,
@@ -1505,11 +1500,11 @@ export function ArchivedThreadsPanel() { · 6 unmodified lines
1505 }),1500 }),
1506 }))1501 }))
1507 .filter((group) => group.threads.length > 0);1502 .filter((group) => group.threads.length > 0);
1508 }, [projectById, projectIds, threadIds, threadShellById]);1503 }, [projects, threads]);
1509 1504
1510 const handleArchivedThreadContextMenu = useCallback(1505 const handleArchivedThreadContextMenu = useCallback(
1511 async (threadId: ThreadId, position: { x: number; y: number }) => {1506 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
1512 const api = readNativeApi();1507 const api = readLocalApi();
1513 if (!api) return;1508 if (!api) return;
1514 const clicked = await api.contextMenu.show(1509 const clicked = await api.contextMenu.show(
1515 [1510 [
@@ -1521,7 +1516,7 @@ export function ArchivedThreadsPanel() { · 5 unmodified lines
1521 1516
1522 if (clicked === "unarchive") {1517 if (clicked === "unarchive") {
1523 try {1518 try {
1524 await unarchiveThread(threadId);1519 await unarchiveThread(threadRef);
1525 } catch (error) {1520 } catch (error) {
1526 toastManager.add({1521 toastManager.add({
1527 type: "error",1522 type: "error",
@@ -1533,7 +1528,7 @@ export function ArchivedThreadsPanel() { · 5 unmodified lines
1533 }1528 }
1534 1529
1535 if (clicked === "delete") {1530 if (clicked === "delete") {
1536 await confirmAndDeleteThread(threadId);1531 await confirmAndDeleteThread(threadRef);
1537 }1532 }
1538 },1533 },
1539 [confirmAndDeleteThread, unarchiveThread],1534 [confirmAndDeleteThread, unarchiveThread],
@@ -1566,10 +1561,13 @@ export function ArchivedThreadsPanel() { · 26 unmodified lines
1566 className="flex items-center justify-between gap-3 border-t border-border px-4 py-3 first:border-t-0 sm:px-5"1561 className="flex items-center justify-between gap-3 border-t border-border px-4 py-3 first:border-t-0 sm:px-5"
1567 onContextMenu={(event) => {1562 onContextMenu={(event) => {
1568 event.preventDefault();1563 event.preventDefault();
1569 void handleArchivedThreadContextMenu(thread.id, {1564 void handleArchivedThreadContextMenu(
1570 x: event.clientX,1565 scopeThreadRef(thread.environmentId, thread.id),
1571 y: event.clientY,1566 {
1572 });1567 x: event.clientX,
1568 y: event.clientY,
1569 },
1570 );
1573 }}1571 }}
1574 >1572 >
1575 <div className="min-w-0 flex-1">1573 <div className="min-w-0 flex-1">
@@ -1586,13 +1584,16 @@ export function ArchivedThreadsPanel() { · 10 unmodified lines
1586 size="sm"1584 size="sm"
1587 className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5"1585 className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5"
1588 onClick={() =>1586 onClick={() =>
1589 void unarchiveThread(thread.id).catch((error) => {1587 void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch(
1590 toastManager.add({1588 (error) => {
1591 type: "error",1589 toastManager.add({
1592 title: "Failed to unarchive thread",1590 type: "error",
1593 description: error instanceof Error ? error.message : "An error occurred.",1591 title: "Failed to unarchive thread",
1594 });1592 description:
1595 })1593 error instanceof Error ? error.message : "An error occurred.",
1594 });
1595 },
1596 )
1596 }1597 }
1597 >1598 >
1598 <ArchiveX className="size-3.5" />1599 <ArchiveX className="size-3.5" />
apps/web/src/composerDraftStore.test.ts+302−188
@@ -1,5 +1,12 @@
1import {
2 scopedProjectKey,
3 scopedThreadKey,
4 scopeProjectRef,
5 scopeThreadRef,
6} from "@t3tools/client-runtime";
1import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
2import {8import {
9 EnvironmentId,
3 ProjectId,10 ProjectId,
4 ThreadId,11 ThreadId,
5 type ModelSelection,12 type ModelSelection,
@@ -9,10 +16,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; · 3 unmodified lines
9 16
10import {17import {
11 COMPOSER_DRAFT_STORAGE_KEY,18 COMPOSER_DRAFT_STORAGE_KEY,
12 clearPromotedDraftThread,19 finalizePromotedDraftThreadByRef,
13 clearPromotedDraftThreads,20 markPromotedDraftThread,
21 markPromotedDraftThreadByRef,
22 markPromotedDraftThreads,
23 markPromotedDraftThreadsByRef,
14 type ComposerImageAttachment,24 type ComposerImageAttachment,
15 useComposerDraftStore,25 useComposerDraftStore,
26 DraftId,
16} from "./composerDraftStore";27} from "./composerDraftStore";
17import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";28import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";
18import {29import {
@@ -71,9 +82,9 @@ function makeTerminalContext(input: { · 52 unmodified lines
71 82
72function resetComposerDraftStore() {83function resetComposerDraftStore() {
73 useComposerDraftStore.setState({84 useComposerDraftStore.setState({
74 draftsByThreadId: {},85 draftsByThreadKey: {},
75 draftThreadsByThreadId: {},86 draftThreadsByThreadKey: {},
76 projectDraftThreadIdByProjectId: {},87 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
77 stickyModelSelectionByProvider: {},88 stickyModelSelectionByProvider: {},
78 stickyActiveProvider: null,89 stickyActiveProvider: null,
79 });90 });
@@ -95,8 +106,32 @@ function providerModelOptions(options: ProviderModelOptions): ProviderModelOptio · 15 unmodified lines
95 return options;106 return options;
96}107}
97 108
109const TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
110const OTHER_TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-remote");
111const LEGACY_TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("__legacy__");
112
113function 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
123function 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
128function draftByKey(key: string) {
129 return useComposerDraftStore.getState().draftsByThreadKey[key] ?? undefined;
130}
131
98describe("composerDraftStore addImages", () => {132describe("composerDraftStore addImages", () => {
99 const threadId = ThreadId.makeUnsafe("thread-dedupe");133 const threadId = ThreadId.makeUnsafe("thread-dedupe");
134 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
100 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;135 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
101 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;136 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
102 137
@@ -129,9 +164,9 @@ describe("composerDraftStore addImages", () => { · 26 unmodified lines
129 lastModified: 12345,164 lastModified: 12345,
130 });165 });
131 166
132 useComposerDraftStore.getState().addImages(threadId, [first, duplicate]);167 useComposerDraftStore.getState().addImages(threadRef, [first, duplicate]);
133 168
134 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];169 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
135 expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]);170 expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]);
136 expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate");171 expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate");
137 });172 });
@@ -154,10 +189,10 @@ describe("composerDraftStore addImages", () => { · 16 unmodified lines
154 lastModified: 999,189 lastModified: 999,
155 });190 });
156 191
157 useComposerDraftStore.getState().addImage(threadId, first);192 useComposerDraftStore.getState().addImage(threadRef, first);
158 useComposerDraftStore.getState().addImage(threadId, duplicateLater);193 useComposerDraftStore.getState().addImage(threadRef, duplicateLater);
159 194
160 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];195 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
161 expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]);196 expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]);
162 expect(revokeSpy).toHaveBeenCalledWith("blob:b");197 expect(revokeSpy).toHaveBeenCalledWith("blob:b");
163 });198 });
@@ -172,9 +207,9 @@ describe("composerDraftStore addImages", () => { · 8 unmodified lines
172 previewUrl: "blob:shared",207 previewUrl: "blob:shared",
173 });208 });
174 209
175 useComposerDraftStore.getState().addImages(threadId, [first, duplicateSameUrl]);210 useComposerDraftStore.getState().addImages(threadRef, [first, duplicateSameUrl]);
176 211
177 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];212 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
178 expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]);213 expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]);
179 expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared");214 expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared");
180 });215 });
@@ -182,6 +217,7 @@ describe("composerDraftStore addImages", () => { · 1 unmodified line
182 217
183describe("composerDraftStore clearComposerContent", () => {218describe("composerDraftStore clearComposerContent", () => {
184 const threadId = ThreadId.makeUnsafe("thread-clear");219 const threadId = ThreadId.makeUnsafe("thread-clear");
220 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
185 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;221 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
186 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;222 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
187 223
@@ -201,11 +237,11 @@ describe("composerDraftStore clearComposerContent", () => { · 13 unmodified lines
201 id: "img-optimistic",237 id: "img-optimistic",
202 previewUrl: "blob:optimistic",238 previewUrl: "blob:optimistic",
203 });239 });
204 useComposerDraftStore.getState().addImage(threadId, first);240 useComposerDraftStore.getState().addImage(threadRef, first);
205 241
206 useComposerDraftStore.getState().clearComposerContent(threadId);242 useComposerDraftStore.getState().clearComposerContent(threadRef);
207 243
208 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];244 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
209 expect(draft).toBeUndefined();245 expect(draft).toBeUndefined();
210 expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic");246 expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic");
211 });247 });
@@ -213,13 +249,14 @@ describe("composerDraftStore clearComposerContent", () => { · 1 unmodified line
213 249
214describe("composerDraftStore syncPersistedAttachments", () => {250describe("composerDraftStore syncPersistedAttachments", () => {
215 const threadId = ThreadId.makeUnsafe("thread-sync-persisted");251 const threadId = ThreadId.makeUnsafe("thread-sync-persisted");
252 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
216 253
217 beforeEach(() => {254 beforeEach(() => {
218 removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY);255 removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY);
219 useComposerDraftStore.setState({256 useComposerDraftStore.setState({
220 draftsByThreadId: {},257 draftsByThreadKey: {},
221 draftThreadsByThreadId: {},258 draftThreadsByThreadKey: {},
222 projectDraftThreadIdByProjectId: {},259 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
223 stickyModelSelectionByProvider: {},260 stickyModelSelectionByProvider: {},
224 stickyActiveProvider: null,261 stickyActiveProvider: null,
225 });262 });
@@ -234,7 +271,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { · 8 unmodified lines
234 id: "img-persisted",271 id: "img-persisted",
235 previewUrl: "blob:persisted",272 previewUrl: "blob:persisted",
236 });273 });
237 useComposerDraftStore.getState().addImage(threadId, image);274 useComposerDraftStore.getState().addImage(threadRef, image);
238 setLocalStorageItem(275 setLocalStorageItem(
239 COMPOSER_DRAFT_STORAGE_KEY,276 COMPOSER_DRAFT_STORAGE_KEY,
240 {277 {
@@ -250,7 +287,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { · 9 unmodified lines
250 Schema.Unknown,287 Schema.Unknown,
251 );288 );
252 289
253 useComposerDraftStore.getState().syncPersistedAttachments(threadId, [290 useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [
254 {291 {
255 id: image.id,292 id: image.id,
256 name: image.name,293 name: image.name,
@@ -261,23 +298,20 @@ describe("composerDraftStore syncPersistedAttachments", () => { · 4 unmodified lines
261 ]);298 ]);
262 await Promise.resolve();299 await Promise.resolve();
263 300
264 expect(301 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual([]);
265 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments,302 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.nonPersistedImageIds).toEqual([image.id]);
266 ).toEqual([]);
267 expect(
268 useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedImageIds,
269 ).toEqual([image.id]);
270 });303 });
271});304});
272 305
273describe("composerDraftStore terminal contexts", () => {306describe("composerDraftStore terminal contexts", () => {
274 const threadId = ThreadId.makeUnsafe("thread-dedupe");307 const threadId = ThreadId.makeUnsafe("thread-dedupe");
308 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
275 309
276 beforeEach(() => {310 beforeEach(() => {
277 useComposerDraftStore.setState({311 useComposerDraftStore.setState({
278 draftsByThreadId: {},312 draftsByThreadKey: {},
279 draftThreadsByThreadId: {},313 draftThreadsByThreadKey: {},
280 projectDraftThreadIdByProjectId: {},314 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
281 stickyModelSelectionByProvider: {},315 stickyModelSelectionByProvider: {},
282 stickyActiveProvider: null,316 stickyActiveProvider: null,
283 });317 });
@@ -287,20 +321,20 @@ describe("composerDraftStore terminal contexts", () => { · 3 unmodified lines
287 const first = makeTerminalContext({ id: "ctx-1" });321 const first = makeTerminalContext({ id: "ctx-1" });
288 const duplicate = makeTerminalContext({ id: "ctx-2" });322 const duplicate = makeTerminalContext({ id: "ctx-2" });
289 323
290 useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]);324 useComposerDraftStore.getState().addTerminalContexts(threadRef, [first, duplicate]);
291 325
292 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];326 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
293 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]);327 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]);
294 });328 });
295 329
296 it("clears terminal contexts when clearing composer content", () => {330 it("clears terminal contexts when clearing composer content", () => {
297 useComposerDraftStore331 useComposerDraftStore
298 .getState()332 .getState()
299 .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-1" }));333 .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-1" }));
300 334
301 useComposerDraftStore.getState().clearComposerContent(threadId);335 useComposerDraftStore.getState().clearComposerContent(threadRef);
302 336
303 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();337 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
304 });338 });
305 339
306 it("inserts terminal contexts at the requested inline prompt position", () => {340 it("inserts terminal contexts at the requested inline prompt position", () => {
@@ -311,7 +345,7 @@ describe("composerDraftStore terminal contexts", () => { · 4 unmodified lines
311 useComposerDraftStore345 useComposerDraftStore
312 .getState()346 .getState()
313 .insertTerminalContext(347 .insertTerminalContext(
314 threadId,348 threadRef,
315 firstInsertion.prompt,349 firstInsertion.prompt,
316 makeTerminalContext({ id: "ctx-1" }),350 makeTerminalContext({ id: "ctx-1" }),
317 firstInsertion.contextIndex,351 firstInsertion.contextIndex,
@@ -319,7 +353,7 @@ describe("composerDraftStore terminal contexts", () => { · 1 unmodified line
319 ).toBe(true);353 ).toBe(true);
320 expect(354 expect(
321 useComposerDraftStore.getState().insertTerminalContext(355 useComposerDraftStore.getState().insertTerminalContext(
322 threadId,356 threadRef,
323 secondInsertion.prompt,357 secondInsertion.prompt,
324 makeTerminalContext({358 makeTerminalContext({
325 id: "ctx-2",359 id: "ctx-2",
@@ -331,7 +365,7 @@ describe("composerDraftStore terminal contexts", () => { · 5 unmodified lines
331 ),365 ),
332 ).toBe(true);366 ).toBe(true);
333 367
334 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];368 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
335 expect(draft?.prompt).toBe(369 expect(draft?.prompt).toBe(
336 `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`,370 `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`,
337 );371 );
@@ -341,7 +375,7 @@ describe("composerDraftStore terminal contexts", () => { · 3 unmodified lines
341 it("omits terminal context text from persisted drafts", () => {375 it("omits terminal context text from persisted drafts", () => {
342 useComposerDraftStore376 useComposerDraftStore
343 .getState()377 .getState()
344 .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-persist" }));378 .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" }));
345 379
346 const persistApi = useComposerDraftStore.persist as unknown as {380 const persistApi = useComposerDraftStore.persist as unknown as {
347 getOptions: () => {381 getOptions: () => {
@@ -349,11 +383,12 @@ describe("composerDraftStore terminal contexts", () => { · 1 unmodified line
349 };383 };
350 };384 };
351 const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {385 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>> }>;
353 };387 };
354 388
355 expect(389 expect(
356 persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0],390 persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]
391 ?.terminalContexts?.[0],
357 "Expected terminal context metadata to be persisted.",392 "Expected terminal context metadata to be persisted.",
358 ).toMatchObject({393 ).toMatchObject({
359 id: "ctx-persist",394 id: "ctx-persist",
@@ -363,7 +398,8 @@ describe("composerDraftStore terminal contexts", () => { · 3 unmodified lines
363 lineEnd: 5,398 lineEnd: 5,
364 });399 });
365 expect(400 expect(
366 persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0]?.text,401 persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]
402 ?.terminalContexts?.[0]?.text,
367 ).toBeUndefined();403 ).toBeUndefined();
368 });404 });
369 405
@@ -396,12 +432,12 @@ describe("composerDraftStore terminal contexts", () => { · 26 unmodified lines
396 },432 },
397 },433 },
398 draftThreadsByThreadId: {},434 draftThreadsByThreadId: {},
399 projectDraftThreadIdByProjectId: {},435 projectDraftThreadIdByProjectKey: {},
400 },436 },
401 useComposerDraftStore.getInitialState(),437 useComposerDraftStore.getInitialState(),
402 );438 );
403 439
404 expect(mergedState.draftsByThreadId[threadId]?.terminalContexts).toMatchObject([440 expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.terminalContexts).toMatchObject([
405 {441 {
406 id: "ctx-rehydrated",442 id: "ctx-rehydrated",
407 terminalId: "default",443 terminalId: "default",
@@ -434,22 +470,30 @@ describe("composerDraftStore terminal contexts", () => { · 26 unmodified lines
434 },470 },
435 },471 },
436 draftThreadsByThreadId: "not-an-object",472 draftThreadsByThreadId: "not-an-object",
437 projectDraftThreadIdByProjectId: "not-an-object",473 projectDraftThreadIdByProjectKey: "not-an-object",
438 },474 },
439 useComposerDraftStore.getInitialState(),475 useComposerDraftStore.getInitialState(),
440 );476 );
441 477
442 expect(mergedState.draftsByThreadId[threadId]).toBeUndefined();478 expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined();
443 expect(mergedState.draftThreadsByThreadId).toEqual({});479 expect(mergedState.draftThreadsByThreadKey).toEqual({});
444 expect(mergedState.projectDraftThreadIdByProjectId).toEqual({});480 expect(mergedState.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual({});
445 });481 });
446});482});
447 483
448describe("composerDraftStore project draft thread mapping", () => {484describe("composerDraftStore project draft thread mapping", () => {
449 const projectId = ProjectId.makeUnsafe("project-a");485 const projectId = ProjectId.makeUnsafe("project-a");
450 const otherProjectId = ProjectId.makeUnsafe("project-b");486 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);
451 const threadId = ThreadId.makeUnsafe("thread-a");490 const threadId = ThreadId.makeUnsafe("thread-a");
452 const otherThreadId = ThreadId.makeUnsafe("thread-b");491 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");
453 497
454 beforeEach(() => {498 beforeEach(() => {
455 resetComposerDraftStore();499 resetComposerDraftStore();
@@ -457,17 +501,20 @@ describe("composerDraftStore project draft thread mapping", () => { · 1 unmodified line
457 501
458 it("stores and reads project draft thread ids via actions", () => {502 it("stores and reads project draft thread ids via actions", () => {
459 const store = useComposerDraftStore.getState();503 const store = useComposerDraftStore.getState();
460 expect(store.getDraftThreadByProjectId(projectId)).toBeNull();504 expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
461 expect(store.getDraftThread(threadId)).toBeNull();505 expect(store.getDraftThread(draftId)).toBeNull();
462 506
463 store.setProjectDraftThreadId(projectId, threadId, {507 store.setProjectDraftThreadId(projectRef, draftId, {
508 threadId,
464 branch: "feature/test",509 branch: "feature/test",
465 worktreePath: "/tmp/worktree-test",510 worktreePath: "/tmp/worktree-test",
466 createdAt: "2026-01-01T00:00:00.000Z",511 createdAt: "2026-01-01T00:00:00.000Z",
467 });512 });
468 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({513 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toMatchObject({
469 threadId,514 threadId,
515 environmentId: TEST_ENVIRONMENT_ID,
470 projectId,516 projectId,
517 logicalProjectKey: scopedProjectKey(projectRef),
471 branch: "feature/test",518 branch: "feature/test",
472 worktreePath: "/tmp/worktree-test",519 worktreePath: "/tmp/worktree-test",
473 envMode: "worktree",520 envMode: "worktree",
@@ -475,8 +522,10 @@ describe("composerDraftStore project draft thread mapping", () => { · 1 unmodified line
475 interactionMode: "default",522 interactionMode: "default",
476 createdAt: "2026-01-01T00:00:00.000Z",523 createdAt: "2026-01-01T00:00:00.000Z",
477 });524 });
478 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({525 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
526 environmentId: TEST_ENVIRONMENT_ID,
479 projectId,527 projectId,
528 logicalProjectKey: scopedProjectKey(projectRef),
480 branch: "feature/test",529 branch: "feature/test",
481 worktreePath: "/tmp/worktree-test",530 worktreePath: "/tmp/worktree-test",
482 envMode: "worktree",531 envMode: "worktree",
@@ -488,134 +537,210 @@ describe("composerDraftStore project draft thread mapping", () => { · 5 unmodified lines
488 537
489 it("clears only matching project draft mapping entries", () => {538 it("clears only matching project draft mapping entries", () => {
490 const store = useComposerDraftStore.getState();539 const store = useComposerDraftStore.getState();
491 store.setProjectDraftThreadId(projectId, threadId);540 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
492 store.setPrompt(threadId, "hello");541 store.setPrompt(draftId, "hello");
493 542
494 store.clearProjectDraftThreadById(projectId, otherThreadId);543 store.clearProjectDraftThreadById(projectRef, otherDraftId);
495 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(544 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
496 threadId,545 threadId,
497 );546 );
498 547
499 store.clearProjectDraftThreadById(projectId, threadId);548 store.clearProjectDraftThreadById(projectRef, draftId);
500 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();549 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
501 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();550 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
502 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();551 expect(draftByKey(draftId)).toBeUndefined();
503 });552 });
504 553
505 it("clears project draft mapping by project id", () => {554 it("clears project draft mapping by project id", () => {
506 const store = useComposerDraftStore.getState();555 const store = useComposerDraftStore.getState();
507 store.setProjectDraftThreadId(projectId, threadId);556 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
508 store.setPrompt(threadId, "hello");557 store.setPrompt(draftId, "hello");
509 store.clearProjectDraftThreadId(projectId);558 store.clearProjectDraftThreadId(projectRef);
510 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();559 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
511 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();560 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
512 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();561 expect(draftByKey(draftId)).toBeUndefined();
513 });562 });
514 563
515 it("clears orphaned composer drafts when remapping a project to a new draft thread", () => {564 it("clears orphaned composer drafts when remapping a project to a new draft thread", () => {
516 const store = useComposerDraftStore.getState();565 const store = useComposerDraftStore.getState();
517 store.setProjectDraftThreadId(projectId, threadId);566 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
518 store.setPrompt(threadId, "orphan me");567 store.setPrompt(draftId, "orphan me");
519 568
520 store.setProjectDraftThreadId(projectId, otherThreadId);569 store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId });
521 570
522 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(571 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
523 otherThreadId,572 otherThreadId,
524 );573 );
525 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();574 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
526 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();575 expect(draftByKey(draftId)).toBeUndefined();
527 });576 });
528 577
529 it("keeps composer drafts when the thread is still mapped by another project", () => {578 it("keeps composer drafts when the thread is still mapped by another project", () => {
530 const store = useComposerDraftStore.getState();579 const store = useComposerDraftStore.getState();
531 store.setProjectDraftThreadId(projectId, threadId);580 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
532 store.setProjectDraftThreadId(otherProjectId, threadId);581 store.setProjectDraftThreadId(otherProjectRef, sharedDraftId, { threadId });
533 store.setPrompt(threadId, "keep me");582 store.setPrompt(sharedDraftId, "keep me");
534 583
535 store.clearProjectDraftThreadId(projectId);584 store.clearProjectDraftThreadId(projectRef);
536 585
537 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();586 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
538 expect(587 expect(
539 useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId,588 useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId,
540 ).toBe(threadId);589 ).toBe(threadId);
541 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");590 expect(draftByKey(sharedDraftId)?.prompt).toBe("keep me");
542 });591 });
543 592
544 it("clears draft registration independently", () => {593 it("clears draft registration independently", () => {
545 const store = useComposerDraftStore.getState();594 const store = useComposerDraftStore.getState();
546 store.setProjectDraftThreadId(projectId, threadId);595 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
547 store.setPrompt(threadId, "remove me");596 store.setPrompt(draftId, "remove me");
548 store.clearDraftThread(threadId);597 store.clearDraftThread(draftId);
549 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();598 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
550 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();599 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
551 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();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");
552 });615 });
553 616
554 it("clears a promoted draft by thread id", () => {617 it("reads local draft composer state through a scoped thread ref", () => {
555 const store = useComposerDraftStore.getState();618 const store = useComposerDraftStore.getState();
556 store.setProjectDraftThreadId(projectId, threadId);619 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
557 store.setPrompt(threadId, "promote me");
558 620
559 clearPromotedDraftThread(threadId);621 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
622 store.setPrompt(draftId, "scoped access");
560 623
561 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();624 expect(store.getComposerDraft(draftId)?.prompt).toBe("scoped access");
562 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();625 expect(store.getComposerDraft(threadRef)?.prompt).toBe("scoped access");
563 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
564 });626 });
565 627
566 it("does not clear composer drafts for existing server threads during promotion cleanup", () => {628 it("does not clear composer drafts for existing server threads during promotion cleanup", () => {
567 const store = useComposerDraftStore.getState();629 const store = useComposerDraftStore.getState();
568 store.setPrompt(threadId, "keep me");630 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
631 store.setPrompt(threadRef, "keep me");
569 632
570 clearPromotedDraftThread(threadId);633 markPromotedDraftThread(threadId);
571 634
572 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();635 expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull();
573 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");636 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me");
574 });637 });
575 638
576 it("clears promoted drafts from an iterable of server thread ids", () => {639 it("marks promoted drafts from an iterable of server thread ids", () => {
577 const store = useComposerDraftStore.getState();640 const store = useComposerDraftStore.getState();
578 store.setProjectDraftThreadId(projectId, threadId);641 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
579 store.setPrompt(threadId, "promote me");642 store.setPrompt(draftId, "promote me");
580 store.setProjectDraftThreadId(otherProjectId, otherThreadId);643 store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId });
581 store.setPrompt(otherThreadId, "keep me");644 store.setPrompt(otherDraftId, "keep me");
582 645
583 clearPromotedDraftThreads([threadId]);646 markPromotedDraftThreads([threadId]);
584 647
585 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();648 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual(
586 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();649 scopeThreadRef(TEST_ENVIRONMENT_ID, threadId),
650 );
651 expect(draftByKey(draftId)?.prompt).toBe("promote me");
587 expect(652 expect(
588 useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId,653 useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId,
589 ).toBe(otherThreadId);654 ).toBe(otherThreadId);
590 expect(useComposerDraftStore.getState().draftsByThreadId[otherThreadId]?.prompt).toBe(655 expect(draftByKey(otherDraftId)?.prompt).toBe("keep me");
591 "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,
592 );700 );
701 expect(draftByKey(draftId)?.prompt).toBe("promote me");
593 });702 });
594 703
595 it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => {704 it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => {
596 const store = useComposerDraftStore.getState();705 const store = useComposerDraftStore.getState();
597 store.setPrompt(threadId, "keep me");706 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
707 store.setPrompt(threadRef, "keep me");
598 708
599 clearPromotedDraftThreads([threadId]);709 markPromotedDraftThreads([threadId]);
600 710
601 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();711 expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull();
602 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");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();
603 });726 });
604 727
605 it("updates branch context on an existing draft thread", () => {728 it("updates branch context on an existing draft thread", () => {
606 const store = useComposerDraftStore.getState();729 const store = useComposerDraftStore.getState();
607 store.setProjectDraftThreadId(projectId, threadId, {730 store.setProjectDraftThreadId(projectRef, draftId, {
731 threadId,
608 branch: "main",732 branch: "main",
609 worktreePath: null,733 worktreePath: null,
610 });734 });
611 store.setDraftThreadContext(threadId, {735 store.setDraftThreadContext(draftId, {
612 branch: "feature/next",736 branch: "feature/next",
613 worktreePath: "/tmp/feature-next",737 worktreePath: "/tmp/feature-next",
614 });738 });
615 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(739 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
616 threadId,740 threadId,
617 );741 );
618 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({742 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
743 environmentId: TEST_ENVIRONMENT_ID,
619 projectId,744 projectId,
620 branch: "feature/next",745 branch: "feature/next",
621 worktreePath: "/tmp/feature-next",746 worktreePath: "/tmp/feature-next",
@@ -625,7 +750,8 @@ describe("composerDraftStore project draft thread mapping", () => { · 3 unmodified lines
625 750
626 it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => {751 it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => {
627 const store = useComposerDraftStore.getState();752 const store = useComposerDraftStore.getState();
628 store.setProjectDraftThreadId(projectId, threadId, {753 store.setProjectDraftThreadId(projectRef, draftId, {
754 threadId,
629 branch: "main",755 branch: "main",
630 worktreePath: "/tmp/main-worktree",756 worktreePath: "/tmp/main-worktree",
631 });757 });
@@ -636,9 +762,10 @@ describe("composerDraftStore project draft thread mapping", () => { · 4 unmodified lines
636 branch?: string | null;762 branch?: string | null;
637 worktreePath?: string | null;763 worktreePath?: string | null;
638 };764 };
639 store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions);765 store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions);
640 766
641 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({767 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
768 environmentId: TEST_ENVIRONMENT_ID,
642 projectId,769 projectId,
643 branch: "main",770 branch: "main",
644 worktreePath: "/tmp/main-worktree",771 worktreePath: "/tmp/main-worktree",
@@ -648,7 +775,8 @@ describe("composerDraftStore project draft thread mapping", () => { · 3 unmodified lines
648 775
649 it("preserves worktree env mode without a worktree path", () => {776 it("preserves worktree env mode without a worktree path", () => {
650 const store = useComposerDraftStore.getState();777 const store = useComposerDraftStore.getState();
651 store.setProjectDraftThreadId(projectId, threadId, {778 store.setProjectDraftThreadId(projectRef, draftId, {
779 threadId,
652 branch: "feature/base",780 branch: "feature/base",
653 worktreePath: null,781 worktreePath: null,
654 envMode: "worktree",782 envMode: "worktree",
@@ -662,9 +790,10 @@ describe("composerDraftStore project draft thread mapping", () => { · 7 unmodified lines
662 worktreePath?: string | null;790 worktreePath?: string | null;
663 envMode?: "local" | "worktree";791 envMode?: "local" | "worktree";
664 };792 };
665 store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions);793 store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions);
666 794
667 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({795 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
796 environmentId: TEST_ENVIRONMENT_ID,
668 projectId,797 projectId,
669 branch: "feature/base",798 branch: "feature/base",
670 worktreePath: null,799 worktreePath: null,
@@ -675,6 +804,7 @@ describe("composerDraftStore project draft thread mapping", () => { · 4 unmodified lines
675 804
676describe("composerDraftStore modelSelection", () => {805describe("composerDraftStore modelSelection", () => {
677 const threadId = ThreadId.makeUnsafe("thread-model-options");806 const threadId = ThreadId.makeUnsafe("thread-model-options");
807 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
678 808
679 beforeEach(() => {809 beforeEach(() => {
680 resetComposerDraftStore();810 resetComposerDraftStore();
@@ -683,16 +813,14 @@ describe("composerDraftStore modelSelection", () => { · 2 unmodified lines
683 it("stores a model selection in the draft", () => {813 it("stores a model selection in the draft", () => {
684 const store = useComposerDraftStore.getState();814 const store = useComposerDraftStore.getState();
685 store.setModelSelection(815 store.setModelSelection(
686 threadId,816 threadRef,
687 modelSelection("codex", "gpt-5.3-codex", {817 modelSelection("codex", "gpt-5.3-codex", {
688 reasoningEffort: "xhigh",818 reasoningEffort: "xhigh",
689 fastMode: true,819 fastMode: true,
690 }),820 }),
691 );821 );
692 822
693 expect(823 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
694 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
695 ).toEqual(
696 modelSelection("codex", "gpt-5.3-codex", {824 modelSelection("codex", "gpt-5.3-codex", {
697 reasoningEffort: "xhigh",825 reasoningEffort: "xhigh",
698 fastMode: true,826 fastMode: true,
@@ -702,18 +830,18 @@ describe("composerDraftStore modelSelection", () => { · 3 unmodified lines
702 830
703 it("keeps default-only model selections on the draft", () => {831 it("keeps default-only model selections on the draft", () => {
704 const store = useComposerDraftStore.getState();832 const store = useComposerDraftStore.getState();
705 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4"));833 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4"));
706 834
707 expect(835 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
708 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,836 modelSelection("codex", "gpt-5.4"),
709 ).toEqual(modelSelection("codex", "gpt-5.4"));837 );
710 });838 });
711 839
712 it("replaces only the targeted provider options on the current model selection", () => {840 it("replaces only the targeted provider options on the current model selection", () => {
713 const store = useComposerDraftStore.getState();841 const store = useComposerDraftStore.getState();
714 842
715 store.setModelSelection(843 store.setModelSelection(
716 threadId,844 threadRef,
717 modelSelection("claudeAgent", "claude-opus-4-6", {845 modelSelection("claudeAgent", "claude-opus-4-6", {
718 effort: "max",846 effort: "max",
719 fastMode: true,847 fastMode: true,
@@ -727,7 +855,7 @@ describe("composerDraftStore modelSelection", () => { · 7 unmodified lines
727 );855 );
728 856
729 store.setProviderModelOptions(857 store.setProviderModelOptions(
730 threadId,858 threadRef,
731 "claudeAgent",859 "claudeAgent",
732 {860 {
733 thinking: false,861 thinking: false,
@@ -735,10 +863,7 @@ describe("composerDraftStore modelSelection", () => { · 1 unmodified line
735 { persistSticky: true },863 { persistSticky: true },
736 );864 );
737 865
738 expect(866 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
739 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
740 .claudeAgent,
741 ).toEqual(
742 modelSelection("claudeAgent", "claude-opus-4-6", {867 modelSelection("claudeAgent", "claude-opus-4-6", {
743 thinking: false,868 thinking: false,
744 }),869 }),
@@ -754,20 +879,17 @@ describe("composerDraftStore modelSelection", () => { · 9 unmodified lines
754 const store = useComposerDraftStore.getState();879 const store = useComposerDraftStore.getState();
755 880
756 store.setModelSelection(881 store.setModelSelection(
757 threadId,882 threadRef,
758 modelSelection("claudeAgent", "claude-opus-4-6", {883 modelSelection("claudeAgent", "claude-opus-4-6", {
759 effort: "max",884 effort: "max",
760 }),885 }),
761 );886 );
762 887
763 store.setProviderModelOptions(threadId, "claudeAgent", {888 store.setProviderModelOptions(threadRef, "claudeAgent", {
764 thinking: true,889 thinking: true,
765 });890 });
766 891
767 expect(892 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
768 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
769 .claudeAgent,
770 ).toEqual(
771 modelSelection("claudeAgent", "claude-opus-4-6", {893 modelSelection("claudeAgent", "claude-opus-4-6", {
772 thinking: true,894 thinking: true,
773 }),895 }),
@@ -778,16 +900,14 @@ describe("composerDraftStore modelSelection", () => { · 4 unmodified lines
778 it("keeps explicit off/default codex overrides on the selection", () => {900 it("keeps explicit off/default codex overrides on the selection", () => {
779 const store = useComposerDraftStore.getState();901 const store = useComposerDraftStore.getState();
780 902
781 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true }));903 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true }));
782 904
783 store.setProviderModelOptions(threadId, "codex", {905 store.setProviderModelOptions(threadRef, "codex", {
784 reasoningEffort: "high",906 reasoningEffort: "high",
785 fastMode: false,907 fastMode: false,
786 });908 });
787 909
788 expect(910 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
789 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
790 ).toEqual(
791 modelSelection("codex", "gpt-5.4", {911 modelSelection("codex", "gpt-5.4", {
792 reasoningEffort: "high",912 reasoningEffort: "high",
793 fastMode: false,913 fastMode: false,
@@ -802,18 +922,15 @@ describe("composerDraftStore modelSelection", () => { · 8 unmodified lines
802 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),922 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
803 );923 );
804 store.setModelSelection(924 store.setModelSelection(
805 threadId,925 threadRef,
806 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),926 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
807 );927 );
808 928
809 store.setProviderModelOptions(threadId, "claudeAgent", {929 store.setProviderModelOptions(threadRef, "claudeAgent", {
810 thinking: false,930 thinking: false,
811 });931 });
812 932
813 expect(933 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
814 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
815 .claudeAgent,
816 ).toEqual(
817 modelSelection("claudeAgent", "claude-opus-4-6", {934 modelSelection("claudeAgent", "claude-opus-4-6", {
818 thinking: false,935 thinking: false,
819 }),936 }),
@@ -828,7 +945,7 @@ describe("composerDraftStore modelSelection", () => { · 8 unmodified lines
828 945
829 // Set options for both providers946 // Set options for both providers
830 store.setModelOptions(947 store.setModelOptions(
831 threadId,948 threadRef,
832 providerModelOptions({949 providerModelOptions({
833 codex: { fastMode: true },950 codex: { fastMode: true },
834 claudeAgent: { effort: "max" },951 claudeAgent: { effort: "max" },
@@ -836,9 +953,9 @@ describe("composerDraftStore modelSelection", () => { · 1 unmodified line
836 );953 );
837 954
838 // Now set options for only codex — claudeAgent should be untouched955 // 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" } }));
840 957
841 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];958 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
842 expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" });959 expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" });
843 expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" });960 expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" });
844 });961 });
@@ -847,16 +964,16 @@ describe("composerDraftStore modelSelection", () => { · 2 unmodified lines
847 const store = useComposerDraftStore.getState();964 const store = useComposerDraftStore.getState();
848 965
849 store.setModelOptions(966 store.setModelOptions(
850 threadId,967 threadRef,
851 providerModelOptions({968 providerModelOptions({
852 codex: { fastMode: true },969 codex: { fastMode: true },
853 claudeAgent: { effort: "max" },970 claudeAgent: { effort: "max" },
854 }),971 }),
855 );972 );
856 973
857 store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6"));974 store.setModelSelection(threadRef, modelSelection("claudeAgent", "claude-opus-4-6"));
858 975
859 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];976 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
860 expect(draft?.modelSelectionByProvider.claudeAgent).toEqual(977 expect(draft?.modelSelectionByProvider.claudeAgent).toEqual(
861 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),978 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
862 );979 );
@@ -867,10 +984,10 @@ describe("composerDraftStore modelSelection", () => { · 4 unmodified lines
867 it("creates the first sticky snapshot from provider option changes", () => {984 it("creates the first sticky snapshot from provider option changes", () => {
868 const store = useComposerDraftStore.getState();985 const store = useComposerDraftStore.getState();
869 986
870 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4"));987 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4"));
871 988
872 store.setProviderModelOptions(989 store.setProviderModelOptions(
873 threadId,990 threadRef,
874 "codex",991 "codex",
875 {992 {
876 fastMode: true,993 fastMode: true,
@@ -892,12 +1009,12 @@ describe("composerDraftStore modelSelection", () => { · 15 unmodified lines
892 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),1009 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
893 );1010 );
894 store.setModelSelection(1011 store.setModelSelection(
895 threadId,1012 threadRef,
896 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),1013 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
897 );1014 );
898 1015
899 store.setProviderModelOptions(1016 store.setProviderModelOptions(
900 threadId,1017 threadRef,
901 "claudeAgent",1018 "claudeAgent",
902 {1019 {
903 thinking: false,1020 thinking: false,
@@ -905,10 +1022,7 @@ describe("composerDraftStore modelSelection", () => { · 1 unmodified line
905 { persistSticky: false },1022 { persistSticky: false },
906 );1023 );
907 1024
908 expect(1025 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
909 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
910 .claudeAgent,
911 ).toEqual(
912 modelSelection("claudeAgent", "claude-opus-4-6", {1026 modelSelection("claudeAgent", "claude-opus-4-6", {
913 thinking: false,1027 thinking: false,
914 }),1028 }),
@@ -921,6 +1035,7 @@ describe("composerDraftStore modelSelection", () => { · 6 unmodified lines
921 1035
922describe("composerDraftStore setModelSelection", () => {1036describe("composerDraftStore setModelSelection", () => {
923 const threadId = ThreadId.makeUnsafe("thread-model");1037 const threadId = ThreadId.makeUnsafe("thread-model");
1038 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
924 1039
925 beforeEach(() => {1040 beforeEach(() => {
926 resetComposerDraftStore();1041 resetComposerDraftStore();
@@ -929,11 +1044,11 @@ describe("composerDraftStore setModelSelection", () => { · 2 unmodified lines
929 it("keeps explicit model overrides instead of coercing to null", () => {1044 it("keeps explicit model overrides instead of coercing to null", () => {
930 const store = useComposerDraftStore.getState();1045 const store = useComposerDraftStore.getState();
931 1046
932 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.3-codex"));1047 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.3-codex"));
933 1048
934 expect(1049 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
935 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,1050 modelSelection("codex", "gpt-5.3-codex"),
936 ).toEqual(modelSelection("codex", "gpt-5.3-codex"));1051 );
937 });1052 });
938});1053});
939 1054
@@ -975,11 +1090,12 @@ describe("composerDraftStore sticky composer settings", () => { · 35 unmodified lines
975 it("applies sticky activeProvider to new drafts", () => {1090 it("applies sticky activeProvider to new drafts", () => {
976 const store = useComposerDraftStore.getState();1091 const store = useComposerDraftStore.getState();
977 const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider");1092 const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider");
1093 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
978 1094
979 store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6"));1095 store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6"));
980 store.applyStickyState(threadId);1096 store.applyStickyState(threadRef);
981 1097
982 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({1098 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({
983 modelSelectionByProvider: {1099 modelSelectionByProvider: {
984 claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"),1100 claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"),
985 },1101 },
@@ -990,6 +1106,7 @@ describe("composerDraftStore sticky composer settings", () => { · 4 unmodified lines
990 1106
991describe("composerDraftStore provider-scoped option updates", () => {1107describe("composerDraftStore provider-scoped option updates", () => {
992 const threadId = ThreadId.makeUnsafe("thread-provider");1108 const threadId = ThreadId.makeUnsafe("thread-provider");
1109 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
993 1110
994 beforeEach(() => {1111 beforeEach(() => {
995 resetComposerDraftStore();1112 resetComposerDraftStore();
@@ -998,13 +1115,13 @@ describe("composerDraftStore provider-scoped option updates", () => { · 2 unmodified lines
998 it("retains off-provider option memory without changing the active selection", () => {1115 it("retains off-provider option memory without changing the active selection", () => {
999 const store = useComposerDraftStore.getState();1116 const store = useComposerDraftStore.getState();
1000 store.setModelSelection(1117 store.setModelSelection(
1001 threadId,1118 threadRef,
1002 modelSelection("codex", "gpt-5.3-codex", {1119 modelSelection("codex", "gpt-5.3-codex", {
1003 reasoningEffort: "medium",1120 reasoningEffort: "medium",
1004 }),1121 }),
1005 );1122 );
1006 store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" });1123 store.setProviderModelOptions(threadRef, "claudeAgent", { effort: "max" });
1007 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];1124 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
1008 expect(draft?.modelSelectionByProvider.codex).toEqual(1125 expect(draft?.modelSelectionByProvider.codex).toEqual(
1009 modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }),1126 modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }),
1010 );1127 );
@@ -1015,6 +1132,7 @@ describe("composerDraftStore provider-scoped option updates", () => { · 4 unmodified lines
1015 1132
1016describe("composerDraftStore runtime and interaction settings", () => {1133describe("composerDraftStore runtime and interaction settings", () => {
1017 const threadId = ThreadId.makeUnsafe("thread-settings");1134 const threadId = ThreadId.makeUnsafe("thread-settings");
1135 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1018 1136
1019 beforeEach(() => {1137 beforeEach(() => {
1020 resetComposerDraftStore();1138 resetComposerDraftStore();
@@ -1023,32 +1141,28 @@ describe("composerDraftStore runtime and interaction settings", () => { · 2 unmodified lines
1023 it("stores runtime mode overrides in the composer draft", () => {1141 it("stores runtime mode overrides in the composer draft", () => {
1024 const store = useComposerDraftStore.getState();1142 const store = useComposerDraftStore.getState();
1025 1143
1026 store.setRuntimeMode(threadId, "approval-required");1144 store.setRuntimeMode(threadRef, "approval-required");
1027 1145
1028 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.runtimeMode).toBe(1146 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.runtimeMode).toBe("approval-required");
1029 "approval-required",
1030 );
1031 });1147 });
1032 1148
1033 it("stores interaction mode overrides in the composer draft", () => {1149 it("stores interaction mode overrides in the composer draft", () => {
1034 const store = useComposerDraftStore.getState();1150 const store = useComposerDraftStore.getState();
1035 1151
1036 store.setInteractionMode(threadId, "plan");1152 store.setInteractionMode(threadRef, "plan");
1037 1153
1038 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.interactionMode).toBe(1154 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.interactionMode).toBe("plan");
1039 "plan",
1040 );
1041 });1155 });
1042 1156
1043 it("removes empty settings-only drafts when overrides are cleared", () => {1157 it("removes empty settings-only drafts when overrides are cleared", () => {
1044 const store = useComposerDraftStore.getState();1158 const store = useComposerDraftStore.getState();
1045 1159
1046 store.setRuntimeMode(threadId, "approval-required");1160 store.setRuntimeMode(threadRef, "approval-required");
1047 store.setInteractionMode(threadId, "plan");1161 store.setInteractionMode(threadRef, "plan");
1048 store.setRuntimeMode(threadId, null);1162 store.setRuntimeMode(threadRef, null);
1049 store.setInteractionMode(threadId, null);1163 store.setInteractionMode(threadRef, null);
1050 1164
1051 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();1165 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
1052 });1166 });
1053});1167});
1054 1168
apps/web/src/composerDraftStore.ts+1572−948
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
3 type ClaudeCodeEffort,3 type ClaudeCodeEffort,
4 type CodexReasoningEffort,4 type CodexReasoningEffort,
5 DEFAULT_MODEL_BY_PROVIDER,5 DEFAULT_MODEL_BY_PROVIDER,
6 type EnvironmentId,
6 ModelSelection,7 ModelSelection,
7 ProjectId,8 ProjectId,
8 ProviderInteractionMode,9 ProviderInteractionMode,
@@ -10,8 +11,18 @@ import { · 1 unmodified line
10 ProviderModelOptions,11 ProviderModelOptions,
11 RuntimeMode,12 RuntimeMode,
12 type ServerProvider,13 type ServerProvider,
14 type ScopedProjectRef,
15 type ScopedThreadRef,
13 ThreadId,16 ThreadId,
14} from "@t3tools/contracts";17} from "@t3tools/contracts";
18import {
19 parseScopedProjectKey,
20 parseScopedThreadKey,
21 scopedProjectKey,
22 scopeProjectRef,
23 scopedThreadKey,
24 scopeThreadRef,
25} from "@t3tools/client-runtime";
15import * as Schema from "effect/Schema";26import * as Schema from "effect/Schema";
16import * as Equal from "effect/Equal";27import * as Equal from "effect/Equal";
17import { DeepMutable } from "effect/Types";28import { DeepMutable } from "effect/Types";
@@ -32,10 +43,13 @@ import { getDefaultServerModel } from "./providerModels"; · 14 unmodified lines
32import { UnifiedSettings } from "@t3tools/contracts/settings";43import { UnifiedSettings } from "@t3tools/contracts/settings";
33 44
34export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";45export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
35const COMPOSER_DRAFT_STORAGE_VERSION = 3;46const COMPOSER_DRAFT_STORAGE_VERSION = 5;
36const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);47const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
37export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;48export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
38 49
50export const DraftId = Schema.String.pipe(Schema.brand("DraftId"));
51export type DraftId = typeof DraftId.Type;
52
39const COMPOSER_PERSIST_DEBOUNCE_MS = 300;53const COMPOSER_PERSIST_DEBOUNCE_MS = 300;
40 54
41const composerDebouncedStorage = createDebouncedStorage(55const composerDebouncedStorage = createDebouncedStorage(
@@ -44,7 +58,7 @@ const composerDebouncedStorage = createDebouncedStorage( · 2 unmodified lines
44);58);
45 59
46// Flush pending composer draft writes before page unload to prevent data loss.60// Flush pending composer draft writes before page unload to prevent data loss.
47if (typeof window !== "undefined") {61if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
48 window.addEventListener("beforeunload", () => {62 window.addEventListener("beforeunload", () => {
49 composerDebouncedStorage.flush();63 composerDebouncedStorage.flush();
50 });64 });
@@ -122,6 +136,14 @@ type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; · 71 unmodified lines
122type LegacyV2StoreFields = {136type LegacyV2StoreFields = {
123 stickyModelSelection?: ModelSelection | null;137 stickyModelSelection?: ModelSelection | null;
124 stickyModelOptions?: ProviderModelOptions | null;138 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;
125};147};
126 148
127type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState &149type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState &
@@ -129,20 +151,31 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & · 1 unmodified line
129 LegacyV2StoreFields;151 LegacyV2StoreFields;
130 152
131const PersistedDraftThreadState = Schema.Struct({153const PersistedDraftThreadState = Schema.Struct({
154 threadId: ThreadId,
155 environmentId: Schema.String,
132 projectId: ProjectId,156 projectId: ProjectId,
157 logicalProjectKey: Schema.optionalKey(Schema.String),
133 createdAt: Schema.String,158 createdAt: Schema.String,
134 runtimeMode: RuntimeMode,159 runtimeMode: RuntimeMode,
135 interactionMode: ProviderInteractionMode,160 interactionMode: ProviderInteractionMode,
136 branch: Schema.NullOr(Schema.String),161 branch: Schema.NullOr(Schema.String),
137 worktreePath: Schema.NullOr(Schema.String),162 worktreePath: Schema.NullOr(Schema.String),
138 envMode: DraftThreadEnvModeSchema,163 envMode: DraftThreadEnvModeSchema,
164 promotedTo: Schema.optionalKey(
165 Schema.NullOr(
166 Schema.Struct({
167 environmentId: Schema.String,
168 threadId: Schema.String,
169 }),
170 ),
171 ),
139});172});
140type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type;173type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type;
141 174
142const PersistedComposerDraftStoreState = Schema.Struct({175const PersistedComposerDraftStoreState = Schema.Struct({
143 draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState),176 draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState),
144 draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState),177 draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState),
145 projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId),178 logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String),
146 stickyModelSelectionByProvider: Schema.optionalKey(179 stickyModelSelectionByProvider: Schema.optionalKey(
147 Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)),180 Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)),
148 ),181 ),
@@ -155,6 +188,10 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ · 6 unmodified lines
155 state: PersistedComposerDraftStoreState,188 state: PersistedComposerDraftStoreState,
156});189});
157 190
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 */
158export interface ComposerThreadDraftState {195export interface ComposerThreadDraftState {
159 prompt: string;196 prompt: string;
160 images: ComposerImageAttachment[];197 images: ComposerImageAttachment[];
@@ -167,32 +204,94 @@ export interface ComposerThreadDraftState { · 6 unmodified lines
167 interactionMode: ProviderInteractionMode | null;204 interactionMode: ProviderInteractionMode | null;
168}205}
169 206
170export 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 */
213export interface DraftSessionState {
214 threadId: ThreadId;
215 environmentId: EnvironmentId;
171 projectId: ProjectId;216 projectId: ProjectId;
217 logicalProjectKey: string;
172 createdAt: string;218 createdAt: string;
173 runtimeMode: RuntimeMode;219 runtimeMode: RuntimeMode;
174 interactionMode: ProviderInteractionMode;220 interactionMode: ProviderInteractionMode;
175 branch: string | null;221 branch: string | null;
176 worktreePath: string | null;222 worktreePath: string | null;
177 envMode: DraftThreadEnvMode;223 envMode: DraftThreadEnvMode;
224 promotedTo?: ScopedThreadRef | null;
178}225}
179 226
180interface ProjectDraftThread extends DraftThreadState {227export type DraftThreadState = DraftSessionState;
181 threadId: ThreadId;228
229/**
230 * Draft session metadata paired with its stable draft-session identity.
231 */
232interface ProjectDraftSession extends DraftSessionState {
233 draftId: DraftId;
182}234}
183 235
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 */
244type 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 */
184interface ComposerDraftStoreState {253interface ComposerDraftStoreState {
185 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;254 draftsByThreadKey: Record<string, ComposerThreadDraftState>;
186 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;255 draftThreadsByThreadKey: Record<string, DraftThreadState>;
187 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;256 logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>;
188 stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;257 stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
189 stickyActiveProvider: ProviderKind | null;258 stickyActiveProvider: ProviderKind | null;
190 getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null;259 /** Returns the editable composer content for a draft session or server thread. */
191 getDraftThread: (threadId: ThreadId) => DraftThreadState | null;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. */
192 setProjectDraftThreadId: (290 setProjectDraftThreadId: (
193 projectId: ProjectId,291 projectRef: ScopedProjectRef,
194 threadId: ThreadId,292 draftId: DraftId,
195 options?: {293 options?: {
294 threadId?: ThreadId;
196 branch?: string | null;295 branch?: string | null;
197 worktreePath?: string | null;296 worktreePath?: string | null;
198 createdAt?: string;297 createdAt?: string;
@@ -201,65 +300,76 @@ interface ComposerDraftStoreState { · 2 unmodified lines
201 interactionMode?: ProviderInteractionMode;300 interactionMode?: ProviderInteractionMode;
202 },301 },
203 ) => void;302 ) => void;
303 /** Updates mutable draft-session metadata without touching composer content. */
204 setDraftThreadContext: (304 setDraftThreadContext: (
205 threadId: ThreadId,305 threadRef: ComposerThreadTarget,
206 options: {306 options: {
207 branch?: string | null;307 branch?: string | null;
208 worktreePath?: string | null;308 worktreePath?: string | null;
209 projectId?: ProjectId;309 projectRef?: ScopedProjectRef;
210 createdAt?: string;310 createdAt?: string;
211 envMode?: DraftThreadEnvMode;311 envMode?: DraftThreadEnvMode;
212 runtimeMode?: RuntimeMode;312 runtimeMode?: RuntimeMode;
213 interactionMode?: ProviderInteractionMode;313 interactionMode?: ProviderInteractionMode;
214 },314 },
215 ) => void;315 ) => void;
216 clearProjectDraftThreadId: (projectId: ProjectId) => void;316 clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void;
217 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;317 clearProjectDraftThreadById: (
218 clearDraftThread: (threadId: ThreadId) => void;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;
219 setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void;326 setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void;
220 setPrompt: (threadId: ThreadId, prompt: string) => void;327 setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void;
221 setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;328 setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void;
222 setModelSelection: (329 setModelSelection: (
223 threadId: ThreadId,330 threadRef: ComposerThreadTarget,
224 modelSelection: ModelSelection | null | undefined,331 modelSelection: ModelSelection | null | undefined,
225 ) => void;332 ) => void;
226 setModelOptions: (333 setModelOptions: (
227 threadId: ThreadId,334 threadRef: ComposerThreadTarget,
228 modelOptions: ProviderModelOptions | null | undefined,335 modelOptions: ProviderModelOptions | null | undefined,
229 ) => void;336 ) => void;
230 applyStickyState: (threadId: ThreadId) => void;337 applyStickyState: (threadRef: ComposerThreadTarget) => void;
231 setProviderModelOptions: (338 setProviderModelOptions: (
232 threadId: ThreadId,339 threadRef: ComposerThreadTarget,
233 provider: ProviderKind,340 provider: ProviderKind,
234 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,341 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
235 options?: {342 options?: {
236 persistSticky?: boolean;343 persistSticky?: boolean;
237 },344 },
238 ) => void;345 ) => void;
239 setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void;346 setRuntimeMode: (
347 threadRef: ComposerThreadTarget,
348 runtimeMode: RuntimeMode | null | undefined,
349 ) => void;
240 setInteractionMode: (350 setInteractionMode: (
241 threadId: ThreadId,351 threadRef: ComposerThreadTarget,
242 interactionMode: ProviderInteractionMode | null | undefined,352 interactionMode: ProviderInteractionMode | null | undefined,
243 ) => void;353 ) => void;
244 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;354 addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void;
245 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;355 addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void;
246 removeImage: (threadId: ThreadId, imageId: string) => void;356 removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void;
247 insertTerminalContext: (357 insertTerminalContext: (
248 threadId: ThreadId,358 threadRef: ComposerThreadTarget,
249 prompt: string,359 prompt: string,
250 context: TerminalContextDraft,360 context: TerminalContextDraft,
251 index: number,361 index: number,
252 ) => boolean;362 ) => boolean;
253 addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void;363 addTerminalContext: (threadRef: ComposerThreadTarget, context: TerminalContextDraft) => void;
254 addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;364 addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void;
255 removeTerminalContext: (threadId: ThreadId, contextId: string) => void;365 removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void;
256 clearTerminalContexts: (threadId: ThreadId) => void;366 clearTerminalContexts: (threadRef: ComposerThreadTarget) => void;
257 clearPersistedAttachments: (threadId: ThreadId) => void;367 clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void;
258 syncPersistedAttachments: (368 syncPersistedAttachments: (
259 threadId: ThreadId,369 threadRef: ComposerThreadTarget,
260 attachments: PersistedComposerImageAttachment[],370 attachments: PersistedComposerImageAttachment[],
261 ) => void;371 ) => void;
262 clearComposerContent: (threadId: ThreadId) => void;372 clearComposerContent: (threadRef: ComposerThreadTarget) => void;
263}373}
264 374
265export interface EffectiveComposerModelState {375export interface EffectiveComposerModelState {
@@ -293,9 +403,9 @@ function modelSelectionByProviderToOptions( · 27 unmodified lines
293}403}
294 404
295const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({405const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({
296 draftsByThreadId: {},406 draftsByThreadKey: {},
297 draftThreadsByThreadId: {},407 draftThreadsByThreadKey: {},
298 projectDraftThreadIdByProjectId: {},408 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
299 stickyModelSelectionByProvider: {},409 stickyModelSelectionByProvider: {},
300 stickyActiveProvider: null,410 stickyActiveProvider: null,
301});411});
@@ -748,35 +858,323 @@ function normalizeDraftThreadEnvMode( · 446 unmodified lines
748 return fallbackWorktreePath ? "worktree" : "local";858 return fallbackWorktreePath ? "worktree" : "local";
749}859}
750 860
861function projectDraftKey(projectRef: ScopedProjectRef): string {
862 return scopedProjectKey(projectRef);
863}
864
865function 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 */
875function 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 */
887function 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
903function composerThreadRefFromKey(threadKey: string): ScopedThreadRef | null {
904 return parseScopedThreadKey(threadKey);
905}
906
907type ComposerThreadLookupState = Pick<
908 ComposerDraftStoreState,
909 "draftsByThreadKey" | "draftThreadsByThreadKey"
910>;
911
912function 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
923function 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
950function 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
964function 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
975function isComposerThreadKeyInUse(mappings: Record<string, string>, threadKey: string): boolean {
976 return Object.values(mappings).includes(threadKey);
977}
978
979function toProjectDraftSession(
980 draftId: DraftId,
981 draftSession: DraftSessionState,
982): ProjectDraftSession {
983 return {
984 draftId,
985 ...draftSession,
986 };
987}
988
989function 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
1026function 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
1036function isDraftThreadPromoting(draftThread: DraftThreadState | null | undefined): boolean {
1037 return draftThread?.promotedTo !== null && draftThread?.promotedTo !== undefined;
1038}
1039
1040function 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
1057function 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
751function normalizePersistedDraftThreads(1086function normalizePersistedDraftThreads(
752 rawDraftThreadsByThreadId: unknown,1087 rawDraftThreadsByThreadId: unknown,
753 rawProjectDraftThreadIdByProjectId: unknown,1088 rawProjectDraftThreadIdByProjectKey: unknown,
754): Pick<1089): Pick<
755 PersistedComposerDraftStoreState,1090 PersistedComposerDraftStoreState,
756 "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId"1091 "draftThreadsByThreadKey" | "logicalProjectDraftThreadKeyByLogicalProjectKey"
757> {1092> {
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 }
759 if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") {1117 if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") {
760 for (const [threadId, rawDraftThread] of Object.entries(1118 for (const [threadKeyOrId, rawDraftThread] of Object.entries(
761 rawDraftThreadsByThreadId as Record<string, unknown>,1119 rawDraftThreadsByThreadId as Record<string, unknown>,
762 )) {1120 )) {
763 if (typeof threadId !== "string" || threadId.length === 0) {1121 if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
764 continue;1122 continue;
765 }1123 }
766 if (!rawDraftThread || typeof rawDraftThread !== "object") {1124 if (!rawDraftThread || typeof rawDraftThread !== "object") {
767 continue;1125 continue;
768 }1126 }
769 const candidateDraftThread = rawDraftThread as Record<string, unknown>;1127 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));
770 const projectId = candidateDraftThread.projectId;1142 const projectId = candidateDraftThread.projectId;
771 const createdAt = candidateDraftThread.createdAt;1143 const createdAt = candidateDraftThread.createdAt;
772 const branch = candidateDraftThread.branch;1144 const branch = candidateDraftThread.branch;
773 const worktreePath = candidateDraftThread.worktreePath;1145 const worktreePath = candidateDraftThread.worktreePath;
774 const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null;1146 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) {
776 continue;1164 continue;
777 }1165 }
778 draftThreadsByThreadId[threadId as ThreadId] = {1166 const normalizedEnvironmentId = environmentId as EnvironmentId;
1167 draftThreadsByThreadKey[threadKey] = {
1168 threadId,
1169 environmentId: normalizedEnvironmentId,
779 projectId: projectId as ProjectId,1170 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,
780 createdAt:1178 createdAt:
781 typeof createdAt === "string" && createdAt.length > 01179 typeof createdAt === "string" && createdAt.length > 0
782 ? createdAt1180 ? createdAt
@@ -794,59 +1192,97 @@ function normalizePersistedDraftThreads( · 11 unmodified lines
794 branch: typeof branch === "string" ? branch : null,1192 branch: typeof branch === "string" ? branch : null,
795 worktreePath: normalizedWorktreePath,1193 worktreePath: normalizedWorktreePath,
796 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),1194 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
1195 promotedTo,
797 };1196 };
798 }1197 }
799 }1198 }
800 1199
801 const projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {};1200 const logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> = {};
802 if (1201 if (
803 rawProjectDraftThreadIdByProjectId &&1202 rawProjectDraftThreadIdByProjectKey &&
804 typeof rawProjectDraftThreadIdByProjectId === "object"1203 typeof rawProjectDraftThreadIdByProjectKey === "object"
805 ) {1204 ) {
806 for (const [projectId, threadId] of Object.entries(1205 for (const [logicalProjectKey, threadKeyOrId] of Object.entries(
807 rawProjectDraftThreadIdByProjectId as Record<string, unknown>,1206 rawProjectDraftThreadIdByProjectKey as Record<string, unknown>,
808 )) {1207 )) {
809 if (1208 if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
810 typeof projectId === "string" &&1209 continue;
811 projectId.length > 0 &&1210 }
812 typeof threadId === "string" &&1211 const projectRef = parseScopedProjectKey(logicalProjectKey);
813 threadId.length > 01212 const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
814 ) {1213 const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId);
815 projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId;1214 logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey;
816 if (!draftThreadsByThreadId[threadId as ThreadId]) {1215 if (parsedThreadRef) {
817 draftThreadsByThreadId[threadId as ThreadId] = {1216 environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId);
818 projectId: projectId as ProjectId,1217 }
819 createdAt: new Date().toISOString(),1218 if (!projectRef) {
820 runtimeMode: DEFAULT_RUNTIME_MODE,1219 const existingDraftThread = draftThreadsByThreadKey[threadKey];
821 interactionMode: DEFAULT_INTERACTION_MODE,1220 if (existingDraftThread && !existingDraftThread.logicalProjectKey) {
822 branch: null,1221 draftThreadsByThreadKey[threadKey] = {
823 worktreePath: null,1222 ...existingDraftThread,
824 envMode: "local",1223 logicalProjectKey,
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,
830 };1224 };
831 }1225 }
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 };
832 }1253 }
833 }1254 }
834 }1255 }
835 1256
836 return { draftThreadsByThreadId, projectDraftThreadIdByProjectId };1257 return { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey };
837}1258}
838 1259
839function normalizePersistedDraftsByThreadId(1260function normalizePersistedDraftsByThreadId(
840 rawDraftMap: unknown,1261 rawDraftMap: unknown,
841): PersistedComposerDraftStoreState["draftsByThreadId"] {1262 draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"],
1263): PersistedComposerDraftStoreState["draftsByThreadKey"] {
842 if (!rawDraftMap || typeof rawDraftMap !== "object") {1264 if (!rawDraftMap || typeof rawDraftMap !== "object") {
843 return {};1265 return {};
844 }1266 }
845 1267
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"]> =
847 {};1281 {};
848 for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) {1282 for (const [threadKeyOrId, draftValue] of Object.entries(
849 if (typeof threadId !== "string" || threadId.length === 0) {1283 rawDraftMap as Record<string, unknown>,
1284 )) {
1285 if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
850 continue;1286 continue;
851 }1287 }
852 if (!draftValue || typeof draftValue !== "object") {1288 if (!draftValue || typeof draftValue !== "object") {
@@ -937,7 +1373,19 @@ function normalizePersistedDraftsByThreadId( · 84 unmodified lines
937 ) {1373 ) {
938 continue;1374 continue;
939 }1375 }
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] = {
941 prompt,1389 prompt,
942 attachments,1390 attachments,
943 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),1391 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
@@ -947,7 +1395,7 @@ function normalizePersistedDraftsByThreadId( · 3 unmodified lines
947 };1395 };
948 }1396 }
949 1397
950 return nextDraftsByThreadId;1398 return nextDraftsByThreadKey;
951}1399}
952 1400
953function migratePersistedComposerDraftStoreState(1401function migratePersistedComposerDraftStoreState(
@@ -957,9 +1405,14 @@ function migratePersistedComposerDraftStoreState( · 3 unmodified lines
957 return EMPTY_PERSISTED_DRAFT_STORE_STATE;1405 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
958 }1406 }
959 const candidate = persistedState as LegacyPersistedComposerDraftStoreState;1407 const candidate = persistedState as LegacyPersistedComposerDraftStoreState;
960 const rawDraftMap = candidate.draftsByThreadId;1408 const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId;
961 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;1409 const rawDraftThreadsByThreadId =
962 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;1410 candidate.draftThreadsByThreadKey ?? candidate.draftThreadsByThreadId;
1411 const rawProjectDraftThreadIdByProjectKey =
1412 candidate.logicalProjectDraftThreadKeyByLogicalProjectKey ??
1413 candidate.projectDraftThreadKeyByProjectKey ??
1414 candidate.projectDraftThreadIdByProjectKey ??
1415 candidate.projectDraftThreadIdByProjectId;
963 1416
964 // Migrate sticky state from v2 (dual) to v3 (consolidated)1417 // Migrate sticky state from v2 (dual) to v3 (consolidated)
965 const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {};1418 const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {};
@@ -982,13 +1435,16 @@ function migratePersistedComposerDraftStoreState( · 16 unmodified lines
982 );1435 );
983 const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null;1436 const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null;
984 1437
985 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =1438 const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } =
986 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId);1439 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey);
987 const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap);1440 const draftsByThreadKey = normalizePersistedDraftsByThreadId(
1441 rawDraftMap,
1442 draftThreadsByThreadKey,
1443 );
988 return {1444 return {
989 draftsByThreadId,1445 draftsByThreadKey,
990 draftThreadsByThreadId,1446 draftThreadsByThreadKey,
991 projectDraftThreadIdByProjectId,1447 logicalProjectDraftThreadKeyByLogicalProjectKey,
992 stickyModelSelectionByProvider,1448 stickyModelSelectionByProvider,
993 stickyActiveProvider,1449 stickyActiveProvider,
994 };1450 };
@@ -997,11 +1453,11 @@ function migratePersistedComposerDraftStoreState( · 2 unmodified lines
997function partializeComposerDraftStoreState(1453function partializeComposerDraftStoreState(
998 state: ComposerDraftStoreState,1454 state: ComposerDraftStoreState,
999): PersistedComposerDraftStoreState {1455): PersistedComposerDraftStoreState {
1000 const persistedDraftsByThreadId: DeepMutable<1456 const persistedDraftsByThreadKey: DeepMutable<
1001 PersistedComposerDraftStoreState["draftsByThreadId"]1457 PersistedComposerDraftStoreState["draftsByThreadKey"]
1002 > = {};1458 > = {};
1003 for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {1459 for (const [threadKey, draft] of Object.entries(state.draftsByThreadKey)) {
1004 if (typeof threadId !== "string" || threadId.length === 0) {1460 if (typeof threadKey !== "string" || threadKey.length === 0) {
1005 continue;1461 continue;
1006 }1462 }
1007 const hasModelData =1463 const hasModelData =
@@ -1041,12 +1497,13 @@ function partializeComposerDraftStoreState( · 33 unmodified lines
1041 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),1497 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),
1042 ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}),1498 ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}),
1043 };1499 };
1044 persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;1500 persistedDraftsByThreadKey[threadKey] = persistedDraft;
1045 }1501 }
1046 return {1502 return {
1047 draftsByThreadId: persistedDraftsByThreadId,1503 draftsByThreadKey: persistedDraftsByThreadKey,
1048 draftThreadsByThreadId: state.draftThreadsByThreadId,1504 draftThreadsByThreadKey: state.draftThreadsByThreadKey,
1049 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,1505 logicalProjectDraftThreadKeyByLogicalProjectKey:
1506 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
1050 stickyModelSelectionByProvider: state.stickyModelSelectionByProvider,1507 stickyModelSelectionByProvider: state.stickyModelSelectionByProvider,
1051 stickyActiveProvider: state.stickyActiveProvider,1508 stickyActiveProvider: state.stickyActiveProvider,
1052 };1509 };
@@ -1059,10 +1516,14 @@ function normalizeCurrentPersistedComposerDraftStoreState( · 6 unmodified lines
1059 return EMPTY_PERSISTED_DRAFT_STORE_STATE;1516 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
1060 }1517 }
1061 const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState;1518 const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState;
1062 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =1519 const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } =
1063 normalizePersistedDraftThreads(1520 normalizePersistedDraftThreads(
1064 normalizedPersistedState.draftThreadsByThreadId,1521 normalizedPersistedState.draftThreadsByThreadKey ??
1065 normalizedPersistedState.projectDraftThreadIdByProjectId,1522 normalizedPersistedState.draftThreadsByThreadId,
1523 normalizedPersistedState.logicalProjectDraftThreadKeyByLogicalProjectKey ??
1524 normalizedPersistedState.projectDraftThreadKeyByProjectKey ??
1525 normalizedPersistedState.projectDraftThreadIdByProjectKey ??
1526 normalizedPersistedState.projectDraftThreadIdByProjectId,
1066 );1527 );
1067 1528
1068 // Handle both v3 (modelSelectionByProvider) and v2/legacy formats1529 // Handle both v3 (modelSelectionByProvider) and v2/legacy formats
@@ -1105,16 +1566,19 @@ function normalizeCurrentPersistedComposerDraftStoreState( · 36 unmodified lines
1105 }1566 }
1106 1567
1107 return {1568 return {
1108 draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId),1569 draftsByThreadKey: normalizePersistedDraftsByThreadId(
1109 draftThreadsByThreadId,1570 normalizedPersistedState.draftsByThreadKey ?? normalizedPersistedState.draftsByThreadId,
1110 projectDraftThreadIdByProjectId,1571 draftThreadsByThreadKey,
1572 ),
1573 draftThreadsByThreadKey,
1574 logicalProjectDraftThreadKeyByLogicalProjectKey,
1111 stickyModelSelectionByProvider,1575 stickyModelSelectionByProvider,
1112 stickyActiveProvider,1576 stickyActiveProvider,
1113 };1577 };
1114}1578}
1115 1579
1116function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {1580function readPersistedAttachmentIdsFromStorage(threadKey: string): string[] {
1117 if (threadId.length === 0) {1581 if (threadKey.length === 0) {
1118 return [];1582 return [];
1119 }1583 }
1120 try {1584 try {
@@ -1125,7 +1589,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { · 4 unmodified lines
1125 if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) {1589 if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) {
1126 return [];1590 return [];
1127 }1591 }
1128 return (persisted.state.draftsByThreadId[threadId]?.attachments ?? []).map(1592 return (persisted.state.draftsByThreadKey[threadKey]?.attachments ?? []).map(
1129 (attachment) => attachment.id,1593 (attachment) => attachment.id,
1130 );1594 );
1131 } catch {1595 } catch {
@@ -1134,7 +1598,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { · 2 unmodified lines
1134}1598}
1135 1599
1136function verifyPersistedAttachments(1600function verifyPersistedAttachments(
1137 threadId: ThreadId,1601 threadKey: string,
1138 attachments: PersistedComposerImageAttachment[],1602 attachments: PersistedComposerImageAttachment[],
1139 set: (1603 set: (
1140 partial:1604 partial:
@@ -1149,12 +1613,12 @@ function verifyPersistedAttachments( · 8 unmodified lines
1149 let persistedIdSet = new Set<string>();1613 let persistedIdSet = new Set<string>();
1150 try {1614 try {
1151 composerDebouncedStorage.flush();1615 composerDebouncedStorage.flush();
1152 persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId));1616 persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadKey));
1153 } catch {1617 } catch {
1154 persistedIdSet = new Set();1618 persistedIdSet = new Set();
1155 }1619 }
1156 set((state) => {1620 set((state) => {
1157 const current = state.draftsByThreadId[threadId];1621 const current = state.draftsByThreadKey[threadKey];
1158 if (!current) {1622 if (!current) {
1159 return state;1623 return state;
1160 }1624 }
@@ -1170,13 +1634,13 @@ function verifyPersistedAttachments( · 9 unmodified lines
1170 persistedAttachments,1634 persistedAttachments,
1171 nonPersistedImageIds,1635 nonPersistedImageIds,
1172 };1636 };
1173 const nextDraftsByThreadId = { ...state.draftsByThreadId };1637 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
1174 if (shouldRemoveDraft(nextDraft)) {1638 if (shouldRemoveDraft(nextDraft)) {
1175 delete nextDraftsByThreadId[threadId];1639 delete nextDraftsByThreadKey[threadKey];
1176 } else {1640 } else {
1177 nextDraftsByThreadId[threadId] = nextDraft;1641 nextDraftsByThreadKey[threadKey] = nextDraft;
1178 }1642 }
1179 return { draftsByThreadId: nextDraftsByThreadId };1643 return { draftsByThreadKey: nextDraftsByThreadKey };
1180 });1644 });
1181}1645}
1182 1646
@@ -1258,879 +1722,980 @@ function toHydratedThreadDraft( · 75 unmodified lines
1258 };1722 };
1259}1723}
1260 1724
1261export const useComposerDraftStore = create<ComposerDraftStoreState>()(1725function 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
1755const composerDraftStore = create<ComposerDraftStoreState>()(
1262 persist(1756 persist(
1263 (set, get) => ({1757 (setBase, get) => {
1264 draftsByThreadId: {},1758 const set = setBase;
1265 draftThreadsByThreadId: {},1759
1266 projectDraftThreadIdByProjectId: {},1760 return {
1267 stickyModelSelectionByProvider: {},1761 draftsByThreadKey: {},
1268 stickyActiveProvider: null,1762 draftThreadsByThreadKey: {},
1269 getDraftThreadByProjectId: (projectId) => {1763 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
1270 if (projectId.length === 0) {1764 stickyModelSelectionByProvider: {},
1271 return null;1765 stickyActiveProvider: null,
1272 }1766 getComposerDraft: (target) => getComposerDraftState(get(), target),
1273 const threadId = get().projectDraftThreadIdByProjectId[projectId];1767 getDraftThreadByLogicalProjectKey: (logicalProjectKey) => {
1274 if (!threadId) {1768 return get().getDraftSessionByLogicalProjectKey(logicalProjectKey);
1275 return null;1769 },
1276 }1770 getDraftSessionByLogicalProjectKey: (logicalProjectKey) => {
1277 const draftThread = get().draftThreadsByThreadId[threadId];1771 const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey);
1278 if (!draftThread || draftThread.projectId !== projectId) {1772 if (normalizedLogicalProjectKey.length === 0) {
1279 return null;1773 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;
1369 }1774 }
1370 const nextProjectId = options.projectId ?? existing.projectId;1775 const draftId =
1371 if (nextProjectId.length === 0) {1776 get().logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey];
1372 return state;1777 if (!draftId) {
1778 return null;
1373 }1779 }
1374 const nextWorktreePath =1780 const draftThread = get().draftThreadsByThreadKey[draftId];
1375 options.worktreePath === undefined1781 if (!draftThread || isDraftThreadPromoting(draftThread)) {
1376 ? existing.worktreePath1782 return null;
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;
1401 }1783 }
1402 const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {1784 return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread);
1403 ...state.projectDraftThreadIdByProjectId,1785 },
1404 [nextProjectId]: threadId,1786 getDraftThreadByProjectRef: (projectRef) => {
1405 };1787 return get().getDraftSessionByProjectRef(projectRef);
1406 if (existing.projectId !== nextProjectId) {1788 },
1407 if (nextProjectDraftThreadIdByProjectId[existing.projectId] === threadId) {1789 getDraftSessionByProjectRef: (projectRef) => {
1408 delete nextProjectDraftThreadIdByProjectId[existing.projectId];1790 for (const [draftId, draftThread] of Object.entries(get().draftThreadsByThreadKey)) {
1791 if (isDraftThreadPromoting(draftThread)) {
1792 continue;
1409 }1793 }
1410 }1794 if (
1411 return {1795 draftThread.projectId === projectRef.projectId &&
1412 draftThreadsByThreadId: {1796 draftThread.environmentId === projectRef.environmentId
1413 ...state.draftThreadsByThreadId,1797 ) {
1414 [threadId]: nextDraftThread,1798 return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread);
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];
1441 }1799 }
1442 }1800 }
1443 return {1801 return null;
1444 draftsByThreadId: nextDraftsByThreadId,1802 },
1445 draftThreadsByThreadId: nextDraftThreadsByThreadId,1803 getDraftSession: (draftId) => get().draftThreadsByThreadKey[draftId] ?? null,
1446 projectDraftThreadIdByProjectId: restProjectMappings,1804 getDraftSessionByRef: (threadRef) => {
1447 };1805 for (const draftSession of Object.values(get().draftThreadsByThreadKey)) {
1448 });1806 if (
1449 },1807 draftSession.environmentId === threadRef.environmentId &&
1450 clearProjectDraftThreadById: (projectId, threadId) => {1808 draftSession.threadId === threadRef.threadId
1451 if (projectId.length === 0 || threadId.length === 0) {1809 ) {
1452 return;1810 return draftSession;
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];
1470 }1811 }
1471 }1812 }
1472 return {1813 return null;
1473 draftsByThreadId: nextDraftsByThreadId,1814 },
1474 draftThreadsByThreadId: nextDraftThreadsByThreadId,1815 getDraftThread: (threadRef) => {
1475 projectDraftThreadIdByProjectId: restProjectMappings,1816 if (typeof threadRef === "string") {
1476 };1817 return get().getDraftSession(DraftId.makeUnsafe(threadRef));
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;
1519 }1818 }
1520 const nextMap: Partial<Record<ProviderKind, ModelSelection>> = {1819 return get().getDraftSessionByRef(threadRef);
1521 ...state.stickyModelSelectionByProvider,1820 },
1522 [normalized.provider]: normalized,1821 getDraftThreadByRef: (threadRef) => {
1523 };1822 return get().getDraftSessionByRef(threadRef);
1524 if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {1823 },
1525 return state.stickyActiveProvider === normalized.provider1824 listDraftThreadKeys: () =>
1526 ? state1825 Object.values(get().draftThreadsByThreadKey).map((draftThread) =>
1527 : { stickyActiveProvider: normalized.provider };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;
1528 }1836 }
1529 return {1837 set((state) => {
1530 stickyModelSelectionByProvider: nextMap,1838 const existingThread = state.draftThreadsByThreadKey[draftId];
1531 stickyActiveProvider: normalized.provider,1839 const previousThreadKeyForLogicalProject =
1532 };1840 state.logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey];
1533 });1841 const nextDraftThread = createDraftThreadState(
1534 },1842 projectRef,
1535 applyStickyState: (threadId) => {1843 options?.threadId ?? existingThread?.threadId ?? ThreadId.makeUnsafe(draftId),
1536 if (threadId.length === 0) {1844 normalizedLogicalProjectKey,
1537 return;1845 existingThread,
1538 }1846 options,
1539 set((state) => {1847 );
1540 const stickyMap = state.stickyModelSelectionByProvider;1848 const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId;
1541 const stickyActiveProvider = state.stickyActiveProvider;1849 if (hasSameLogicalMapping && draftThreadsEqual(existingThread, nextDraftThread)) {
1542 if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) {1850 return state;
1543 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;
1544 }1895 }
1545 const existing = state.draftsByThreadId[threadId];1896 set((state) => {
1546 const base = existing ?? createEmptyThreadDraft();1897 const existing = state.draftThreadsByThreadKey[threadKey];
1547 const nextMap = { ...base.modelSelectionByProvider };1898 if (!existing) {
1548 for (const [provider, selection] of Object.entries(stickyMap)) {1899 return state;
1549 if (selection) {
1550 const current = nextMap[provider as ProviderKind];
1551 nextMap[provider as ProviderKind] = {
1552 ...selection,
1553 model: current?.model ?? selection.model,
1554 };
1555 }1900 }
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;
1556 }1971 }
1557 if (1972 set((state) => {
1558 Equal.equals(base.modelSelectionByProvider, nextMap) &&1973 const draftThread = state.draftThreadsByThreadKey[threadKey];
1559 base.activeProvider === stickyActiveProvider1974 if (
1560 ) {1975 !draftThread ||
1561 return state;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;
1562 }1988 }
1563 const nextDraft: ComposerThreadDraftState = {1989 set((state) => {
1564 ...base,1990 const existing = state.draftThreadsByThreadKey[threadKey];
1565 modelSelectionByProvider: nextMap,1991 if (!existing) {
1566 activeProvider: stickyActiveProvider,1992 return state;
1567 };1993 }
1568 const nextDraftsByThreadId = { ...state.draftsByThreadId };1994 const nextPromotedTo =
1569 if (shouldRemoveDraft(nextDraft)) {1995 promotedTo ?? scopeThreadRef(existing.environmentId, existing.threadId);
1570 delete nextDraftsByThreadId[threadId];1996 if (scopedThreadRefsEqual(existing.promotedTo, nextPromotedTo)) {
1571 } else {1997 return state;
1572 nextDraftsByThreadId[threadId] = nextDraft;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;
1573 }2014 }
1574 return { draftsByThreadId: nextDraftsByThreadId };2015 set((state) => {
1575 });2016 const existing = state.draftThreadsByThreadKey[threadKey];
1576 },2017 if (!isDraftThreadPromoting(existing)) {
1577 setPrompt: (threadId, prompt) => {2018 return state;
1578 if (threadId.length === 0) {2019 }
1579 return;2020 return removeDraftThreadReferences(state, threadKey);
1580 }2021 });
1581 set((state) => {2022 },
1582 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();2023 clearDraftThread: (threadRef) => {
1583 const nextDraft: ComposerThreadDraftState = {2024 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
1584 ...existing,2025 if (threadKey.length === 0) {
1585 prompt,2026 return;
1586 };
1587 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1588 if (shouldRemoveDraft(nextDraft)) {
1589 delete nextDraftsByThreadId[threadId];
1590 } else {
1591 nextDraftsByThreadId[threadId] = nextDraft;
1592 }2027 }
1593 return { draftsByThreadId: nextDraftsByThreadId };2028 const existing = get().draftsByThreadKey[threadKey];
1594 });2029 if (existing) {
1595 },2030 for (const image of existing.images) {
1596 setTerminalContexts: (threadId, contexts) => {2031 revokeObjectPreviewUrl(image.previewUrl);
1597 if (threadId.length === 0) {2032 }
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;
1616 }2033 }
1617 return { draftsByThreadId: nextDraftsByThreadId };2034 set((state) => {
1618 });2035 const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined;
1619 },2036 const hasLogicalProjectMapping = Object.values(
1620 setModelSelection: (threadId, modelSelection) => {2037 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
1621 if (threadId.length === 0) {2038 ).includes(threadKey);
1622 return;2039 const hasComposerDraft = state.draftsByThreadKey[threadKey] !== undefined;
1623 }2040 if (!hasDraftThread && !hasLogicalProjectMapping && !hasComposerDraft) {
1624 const normalized = normalizeModelSelection(modelSelection);2041 return state;
1625 set((state) => {2042 }
1626 const existing = state.draftsByThreadId[threadId];2043 return removeDraftThreadReferences(state, threadKey);
1627 if (!existing && normalized === null) {2044 });
1628 return state;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;
1629 }2071 }
1630 const base = existing ?? createEmptyThreadDraft();2072 set((state) => {
1631 const nextMap = { ...base.modelSelectionByProvider };2073 const stickyMap = state.stickyModelSelectionByProvider;
1632 if (normalized) {2074 const stickyActiveProvider = state.stickyActiveProvider;
1633 const current = nextMap[normalized.provider];2075 if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) {
1634 if (normalized.options !== undefined) {2076 return state;
1635 // Explicit options provided → use them2077 }
1636 nextMap[normalized.provider] = normalized;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];
1637 } else {2104 } else {
1638 // No options in selection → preserve existing options, update provider+model2105 nextDraftsByThreadKey[threadKey] = nextDraft;
1639 nextMap[normalized.provider] = {
1640 provider: normalized.provider,
1641 model: normalized.model,
1642 ...(current?.options ? { options: current.options } : {}),
1643 };
1644 }2106 }
2107 return { draftsByThreadKey: nextDraftsByThreadKey };
2108 });
2109 },
2110 setPrompt: (threadRef, prompt) => {
2111 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2112 if (threadKey.length === 0) {
2113 return;
1645 }2114 }
1646 const nextActiveProvider = normalized?.provider ?? base.activeProvider;2115 set((state) => {
1647 if (2116 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
1648 Equal.equals(base.modelSelectionByProvider, nextMap) &&2117 const nextDraft: ComposerThreadDraftState = {
1649 base.activeProvider === nextActiveProvider2118 ...existing,
1650 ) {2119 prompt,
1651 return state;2120 };
1652 }2121 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
1653 const nextDraft: ComposerThreadDraftState = {2122 if (shouldRemoveDraft(nextDraft)) {
1654 ...base,2123 delete nextDraftsByThreadKey[threadKey];
1655 modelSelectionByProvider: nextMap,2124 } else {
1656 activeProvider: nextActiveProvider,2125 nextDraftsByThreadKey[threadKey] = nextDraft;
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;
1694 }2126 }
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;
1695 }2135 }
1696 if (Equal.equals(base.modelSelectionByProvider, nextMap)) {2136 const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts);
1697 return state;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;
1698 }2160 }
1699 const nextDraft: ComposerThreadDraftState = {2161 const normalized = normalizeModelSelection(modelSelection);
1700 ...base,2162 set((state) => {
1701 modelSelectionByProvider: nextMap,2163 const existing = state.draftsByThreadKey[threadKey];
1702 };2164 if (!existing && normalized === null) {
1703 const nextDraftsByThreadId = { ...state.draftsByThreadId };2165 return state;
1704 if (shouldRemoveDraft(nextDraft)) {2166 }
1705 delete nextDraftsByThreadId[threadId];2167 const base = existing ?? createEmptyThreadDraft();
1706 } else {2168 const nextMap = { ...base.modelSelectionByProvider };
1707 nextDraftsByThreadId[threadId] = nextDraft;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;
1708 }2208 }
1709 return { draftsByThreadId: nextDraftsByThreadId };2209 const normalizedOpts = normalizeProviderModelOptions(modelOptions);
1710 });2210 set((state) => {
1711 },2211 const existing = state.draftsByThreadKey[threadKey];
1712 setProviderModelOptions: (threadId, provider, nextProviderOptions, options) => {2212 if (!existing && normalizedOpts === null) {
1713 if (threadId.length === 0) {2213 return state;
1714 return;2214 }
1715 }2215 const base = existing ?? createEmptyThreadDraft();
1716 const normalizedProvider = normalizeProviderKind(provider);2216 const nextMap = { ...base.modelSelectionByProvider };
1717 if (normalizedProvider === null) {2217 for (const provider of ["codex", "claudeAgent"] as const) {
1718 return;2218 // Only touch providers explicitly present in the input
1719 }2219 if (!normalizedOpts || !(provider in normalizedOpts)) continue;
1720 // Normalize just this provider's options2220 const opts = normalizedOpts[provider];
1721 const normalizedOpts = normalizeProviderModelOptions(2221 const current = nextMap[provider];
1722 { [normalizedProvider]: nextProviderOptions },2222 if (opts) {
1723 normalizedProvider,2223 nextMap[provider] = {
1724 );2224 provider,
1725 const providerOpts = normalizedOpts?.[normalizedProvider];2225 model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
1726 2226 options: opts,
1727 set((state) => {2227 };
1728 const existing = state.draftsByThreadId[threadId];2228 } else if (current?.options) {
1729 const base = existing ?? createEmptyThreadDraft();2229 // Remove options but keep the selection
1730 2230 const { options: _, ...rest } = current;
1731 // Update the map entry for this provider2231 nextMap[provider] = rest as ModelSelection;
1732 const nextMap = { ...base.modelSelectionByProvider };2232 }
1733 const currentForProvider = nextMap[normalizedProvider];2233 }
1734 if (providerOpts) {2234 if (Equal.equals(base.modelSelectionByProvider, nextMap)) {
1735 nextMap[normalizedProvider] = {2235 return state;
1736 provider: normalizedProvider,2236 }
1737 model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],2237 const nextDraft: ComposerThreadDraftState = {
1738 options: providerOpts,2238 ...base,
2239 modelSelectionByProvider: nextMap,
1739 };2240 };
1740 } else if (currentForProvider?.options) {2241 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
1741 const { options: _, ...rest } = currentForProvider;2242 if (shouldRemoveDraft(nextDraft)) {
1742 nextMap[normalizedProvider] = rest as ModelSelection;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;
1743 }2254 }
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];
1744 2265
1745 // Handle sticky persistence2266 set((state) => {
1746 let nextStickyMap = state.stickyModelSelectionByProvider;2267 const existing = state.draftsByThreadKey[threadKey];
1747 let nextStickyActiveProvider = state.stickyActiveProvider;2268 const base = existing ?? createEmptyThreadDraft();
1748 if (options?.persistSticky === true) {2269
1749 nextStickyMap = { ...state.stickyModelSelectionByProvider };2270 // Update the map entry for this provider
1750 const stickyBase =2271 const nextMap = { ...base.modelSelectionByProvider };
1751 nextStickyMap[normalizedProvider] ??2272 const currentForProvider = nextMap[normalizedProvider];
1752 base.modelSelectionByProvider[normalizedProvider] ??
1753 ({
1754 provider: normalizedProvider,
1755 model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
1756 } as ModelSelection);
1757 if (providerOpts) {2273 if (providerOpts) {
1758 nextStickyMap[normalizedProvider] = {2274 nextMap[normalizedProvider] = {
1759 ...stickyBase,
1760 provider: normalizedProvider,2275 provider: normalizedProvider,
2276 model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
1761 options: providerOpts,2277 options: providerOpts,
1762 };2278 };
1763 } else if (stickyBase.options) {2279 } else if (currentForProvider?.options) {
1764 const { options: _, ...rest } = stickyBase;2280 const { options: _, ...rest } = currentForProvider;
1765 nextStickyMap[normalizedProvider] = rest as ModelSelection;2281 nextMap[normalizedProvider] = rest as ModelSelection;
1766 }2282 }
1767 nextStickyActiveProvider = base.activeProvider ?? normalizedProvider;
1768 }
1769 2283
1770 if (2284 // Handle sticky persistence
1771 Equal.equals(base.modelSelectionByProvider, nextMap) &&2285 let nextStickyMap = state.stickyModelSelectionByProvider;
1772 Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) &&2286 let nextStickyActiveProvider = state.stickyActiveProvider;
1773 state.stickyActiveProvider === nextStickyActiveProvider2287 if (options?.persistSticky === true) {
1774 ) {2288 nextStickyMap = { ...state.stickyModelSelectionByProvider };
1775 return state;2289 const stickyBase =
1776 }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 }
1777 2308
1778 const nextDraft: ComposerThreadDraftState = {2309 if (
1779 ...base,2310 Equal.equals(base.modelSelectionByProvider, nextMap) &&
1780 modelSelectionByProvider: nextMap,2311 Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) &&
1781 };2312 state.stickyActiveProvider === nextStickyActiveProvider
1782 const nextDraftsByThreadId = { ...state.draftsByThreadId };2313 ) {
1783 if (shouldRemoveDraft(nextDraft)) {2314 return state;
1784 delete nextDraftsByThreadId[threadId];2315 }
1785 } else {
1786 nextDraftsByThreadId[threadId] = nextDraft;
1787 }
1788 2316
1789 return {2317 const nextDraft: ComposerThreadDraftState = {
1790 draftsByThreadId: nextDraftsByThreadId,2318 ...base,
1791 ...(options?.persistSticky === true2319 modelSelectionByProvider: nextMap,
1792 ? {2320 };
1793 stickyModelSelectionByProvider: nextStickyMap,2321 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
1794 stickyActiveProvider: nextStickyActiveProvider,2322 if (shouldRemoveDraft(nextDraft)) {
1795 }2323 delete nextDraftsByThreadKey[threadKey];
1796 : {}),2324 } else {
1797 };2325 nextDraftsByThreadKey[threadKey] = nextDraft;
1798 });2326 }
1799 },2327
1800 setRuntimeMode: (threadId, runtimeMode) => {2328 return {
1801 if (threadId.length === 0) {2329 draftsByThreadKey: nextDraftsByThreadKey,
1802 return;2330 ...(options?.persistSticky === true
1803 }2331 ? {
1804 const nextRuntimeMode =2332 stickyModelSelectionByProvider: nextStickyMap,
1805 runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null;2333 stickyActiveProvider: nextStickyActiveProvider,
1806 set((state) => {2334 }
1807 const existing = state.draftsByThreadId[threadId];2335 : {}),
1808 if (!existing && nextRuntimeMode === null) {2336 };
1809 return state;2337 });
1810 }2338 },
1811 const base = existing ?? createEmptyThreadDraft();2339 setRuntimeMode: (threadRef, runtimeMode) => {
1812 if (base.runtimeMode === nextRuntimeMode) {2340 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
1813 return state;2341 if (threadKey.length === 0) {
1814 }2342 return;
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;
1838 }2343 }
1839 const base = existing ?? createEmptyThreadDraft();2344 const nextRuntimeMode =
1840 if (base.interactionMode === nextInteractionMode) {2345 runtimeMode === "approval-required" || runtimeMode === "full-access"
1841 return state;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;
1842 }2374 }
1843 const nextDraft: ComposerThreadDraftState = {2375 const nextInteractionMode =
1844 ...base,2376 interactionMode === "plan" || interactionMode === "default" ? interactionMode : null;
1845 interactionMode: nextInteractionMode,2377 set((state) => {
1846 };2378 const existing = state.draftsByThreadKey[threadKey];
1847 const nextDraftsByThreadId = { ...state.draftsByThreadId };2379 if (!existing && nextInteractionMode === null) {
1848 if (shouldRemoveDraft(nextDraft)) {2380 return state;
1849 delete nextDraftsByThreadId[threadId];2381 }
1850 } else {2382 const base = existing ?? createEmptyThreadDraft();
1851 nextDraftsByThreadId[threadId] = nextDraft;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;
1852 }2404 }
1853 return { draftsByThreadId: nextDraftsByThreadId };2405 get().addImages(
1854 });2406 typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef,
1855 },2407 [image],
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)),
1871 );2408 );
1872 const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl));2409 },
1873 const dedupedIncoming: ComposerImageAttachment[] = [];2410 addImages: (threadRef, images) => {
1874 for (const image of images) {2411 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
1875 const dedupKey = composerImageDedupKey(image);2412 if (threadKey.length === 0 || images.length === 0) {
1876 if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) {2413 return;
1877 // Avoid revoking a blob URL that's still referenced by an accepted image.2414 }
1878 if (!acceptedPreviewUrls.has(image.previewUrl)) {2415 set((state) => {
1879 revokeObjectPreviewUrl(image.previewUrl);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;
1880 }2431 }
1881 continue;2432 dedupedIncoming.push(image);
2433 existingIds.add(image.id);
2434 existingDedupKeys.add(dedupKey);
2435 acceptedPreviewUrls.add(image.previewUrl);
1882 }2436 }
1883 dedupedIncoming.push(image);2437 if (dedupedIncoming.length === 0) {
1884 existingIds.add(image.id);2438 return state;
1885 existingDedupKeys.add(dedupKey);2439 }
1886 acceptedPreviewUrls.add(image.previewUrl);2440 return {
1887 }2441 draftsByThreadKey: {
1888 if (dedupedIncoming.length === 0) {2442 ...state.draftsByThreadKey,
1889 return state;2443 [threadKey]: {
1890 }2444 ...existing,
1891 return {2445 images: [...existing.images, ...dedupedIncoming],
1892 draftsByThreadId: {2446 },
1893 ...state.draftsByThreadId,
1894 [threadId]: {
1895 ...existing,
1896 images: [...existing.images, ...dedupedIncoming],
1897 },2447 },
1898 },2448 };
1899 };2449 });
1900 });2450 },
1901 },2451 removeImage: (threadRef, imageId) => {
1902 removeImage: (threadId, imageId) => {2452 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
1903 if (threadId.length === 0) {2453 if (threadKey.length === 0) {
1904 return;2454 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;
1932 }2455 }
1933 return { draftsByThreadId: nextDraftsByThreadId };2456 const existing = get().draftsByThreadKey[threadKey];
1934 });2457 if (!existing) {
1935 },2458 return;
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;
1946 }2459 }
1947 const dedupKey = terminalContextDedupKey(normalizedContext);2460 const removedImage = existing.images.find((image) => image.id === imageId);
1948 if (2461 if (removedImage) {
1949 existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) ||2462 revokeObjectPreviewUrl(removedImage.previewUrl);
1950 existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey)
1951 ) {
1952 return state;
1953 }2463 }
1954 inserted = true;2464 set((state) => {
1955 const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index));2465 const current = state.draftsByThreadKey[threadKey];
1956 const nextDraft: ComposerThreadDraftState = {2466 if (!current) {
1957 ...existing,2467 return state;
1958 prompt,2468 }
1959 terminalContexts: [2469 const nextDraft: ComposerThreadDraftState = {
1960 ...existing.terminalContexts.slice(0, boundedIndex),2470 ...current,
1961 normalizedContext,2471 images: current.images.filter((image) => image.id !== imageId),
1962 ...existing.terminalContexts.slice(boundedIndex),2472 nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId),
1963 ],2473 persistedAttachments: current.persistedAttachments.filter(
1964 };2474 (attachment) => attachment.id !== imageId,
1965 return {2475 ),
1966 draftsByThreadId: {2476 };
1967 ...state.draftsByThreadId,2477 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
1968 [threadId]: nextDraft,2478 if (shouldRemoveDraft(nextDraft)) {
1969 },2479 delete nextDraftsByThreadKey[threadKey];
1970 };2480 } else {
1971 });2481 nextDraftsByThreadKey[threadKey] = nextDraft;
1972 return inserted;2482 }
1973 },2483 return { draftsByThreadKey: nextDraftsByThreadKey };
1974 addTerminalContext: (threadId, context) => {2484 });
1975 if (threadId.length === 0) {2485 },
1976 return;2486 insertTerminalContext: (threadRef, prompt, context, index) => {
1977 }2487 const threadKey = resolveComposerDraftKey(get(), threadRef);
1978 get().addTerminalContexts(threadId, [context]);2488 const threadId = resolveComposerThreadId(get(), threadRef);
1979 },2489 if (!threadKey || !threadId) {
1980 addTerminalContexts: (threadId, contexts) => {2490 return false;
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;
1992 }2491 }
1993 return {2492 let inserted = false;
1994 draftsByThreadId: {2493 set((state) => {
1995 ...state.draftsByThreadId,2494 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
1996 [threadId]: {2495 const normalizedContext = normalizeTerminalContextForThread(threadId, context);
1997 ...existing,2496 if (!normalizedContext) {
1998 prompt: ensureInlineTerminalContextPlaceholders(2497 return state;
1999 existing.prompt,2498 }
2000 existing.terminalContexts.length + acceptedContexts.length,2499 const dedupKey = terminalContextDedupKey(normalizedContext);
2001 ),2500 if (
2002 terminalContexts: [...existing.terminalContexts, ...acceptedContexts],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,
2003 },2521 },
2004 },2522 };
2005 };2523 });
2006 });2524 return inserted;
2007 },2525 },
2008 removeTerminalContext: (threadId, contextId) => {2526 addTerminalContext: (threadRef, context) => {
2009 if (threadId.length === 0 || contextId.length === 0) {2527 const threadKey = resolveComposerDraftKey(get(), threadRef);
2010 return;2528 const threadId = resolveComposerThreadId(get(), threadRef);
2011 }2529 if (!threadKey || !threadId) {
2012 set((state) => {2530 return;
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;
2050 }2531 }
2051 return { draftsByThreadId: nextDraftsByThreadId };2532 get().addTerminalContexts(
2052 });2533 typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef,
2053 },2534 [context],
2054 clearPersistedAttachments: (threadId) => {2535 );
2055 if (threadId.length === 0) {2536 },
2056 return;2537 addTerminalContexts: (threadRef, contexts) => {
2057 }2538 const threadKey = resolveComposerDraftKey(get(), threadRef);
2058 set((state) => {2539 const threadId = resolveComposerThreadId(get(), threadRef);
2059 const current = state.draftsByThreadId[threadId];2540 if (!threadKey || !threadId || contexts.length === 0) {
2060 if (!current) {2541 return;
2061 return state;
2062 }2542 }
2063 const nextDraft: ComposerThreadDraftState = {2543 set((state) => {
2064 ...current,2544 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2065 persistedAttachments: [],2545 const acceptedContexts = normalizeTerminalContextsForThread(threadId, [
2066 nonPersistedImageIds: [],2546 ...existing.terminalContexts,
2067 };2547 ...contexts,
2068 const nextDraftsByThreadId = { ...state.draftsByThreadId };2548 ]).slice(existing.terminalContexts.length);
2069 if (shouldRemoveDraft(nextDraft)) {2549 if (acceptedContexts.length === 0) {
2070 delete nextDraftsByThreadId[threadId];2550 return state;
2071 } else {2551 }
2072 nextDraftsByThreadId[threadId] = nextDraft;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;
2073 }2571 }
2074 return { draftsByThreadId: nextDraftsByThreadId };2572 set((state) => {
2075 });2573 const current = state.draftsByThreadKey[threadKey];
2076 },2574 if (!current) {
2077 syncPersistedAttachments: (threadId, attachments) => {2575 return state;
2078 if (threadId.length === 0) {2576 }
2079 return;2577 const nextDraft: ComposerThreadDraftState = {
2080 }2578 ...current,
2081 const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id));2579 terminalContexts: current.terminalContexts.filter(
2082 set((state) => {2580 (context) => context.id !== contextId,
2083 const current = state.draftsByThreadId[threadId];2581 ),
2084 if (!current) {2582 };
2085 return state;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;
2086 }2596 }
2087 const nextDraft: ComposerThreadDraftState = {2597 set((state) => {
2088 ...current,2598 const current = state.draftsByThreadKey[threadKey];
2089 // Stage attempted attachments so persist middleware can try writing them.2599 if (!current || current.terminalContexts.length === 0) {
2090 persistedAttachments: attachments,2600 return state;
2091 nonPersistedImageIds: current.nonPersistedImageIds.filter(2601 }
2092 (id) => !attachmentIdSet.has(id),2602 const nextDraft: ComposerThreadDraftState = {
2093 ),2603 ...current,
2094 };2604 terminalContexts: [],
2095 const nextDraftsByThreadId = { ...state.draftsByThreadId };2605 };
2096 if (shouldRemoveDraft(nextDraft)) {2606 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2097 delete nextDraftsByThreadId[threadId];2607 if (shouldRemoveDraft(nextDraft)) {
2098 } else {2608 delete nextDraftsByThreadKey[threadKey];
2099 nextDraftsByThreadId[threadId] = nextDraft;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;
2100 }2619 }
2101 return { draftsByThreadId: nextDraftsByThreadId };2620 set((state) => {
2102 });2621 const current = state.draftsByThreadKey[threadKey];
2103 Promise.resolve().then(() => {2622 if (!current) {
2104 verifyPersistedAttachments(threadId, attachments, set);2623 return state;
2105 });2624 }
2106 },2625 const nextDraft: ComposerThreadDraftState = {
2107 clearComposerContent: (threadId) => {2626 ...current,
2108 if (threadId.length === 0) {2627 persistedAttachments: [],
2109 return;2628 nonPersistedImageIds: [],
2110 }2629 };
2111 set((state) => {2630 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2112 const current = state.draftsByThreadId[threadId];2631 if (shouldRemoveDraft(nextDraft)) {
2113 if (!current) {2632 delete nextDraftsByThreadKey[threadKey];
2114 return state;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;
2115 }2643 }
2116 const nextDraft: ComposerThreadDraftState = {2644 const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id));
2117 ...current,2645 set((state) => {
2118 prompt: "",2646 const current = state.draftsByThreadKey[threadKey];
2119 images: [],2647 if (!current) {
2120 nonPersistedImageIds: [],2648 return state;
2121 persistedAttachments: [],2649 }
2122 terminalContexts: [],2650 const nextDraft: ComposerThreadDraftState = {
2123 };2651 ...current,
2124 const nextDraftsByThreadId = { ...state.draftsByThreadId };2652 // Stage attempted attachments so persist middleware can try writing them.
2125 if (shouldRemoveDraft(nextDraft)) {2653 persistedAttachments: attachments,
2126 delete nextDraftsByThreadId[threadId];2654 nonPersistedImageIds: current.nonPersistedImageIds.filter(
2127 } else {2655 (id) => !attachmentIdSet.has(id),
2128 nextDraftsByThreadId[threadId] = nextDraft;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;
2129 }2674 }
2130 return { draftsByThreadId: nextDraftsByThreadId };2675 set((state) => {
2131 });2676 const current = state.draftsByThreadKey[threadKey];
2132 },2677 if (!current) {
2133 }),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 },
2134 {2699 {
2135 name: COMPOSER_DRAFT_STORAGE_KEY,2700 name: COMPOSER_DRAFT_STORAGE_KEY,
2136 version: COMPOSER_DRAFT_STORAGE_VERSION,2701 version: COMPOSER_DRAFT_STORAGE_VERSION,
@@ -2140,17 +2705,23 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( · 3 unmodified lines
2140 merge: (persistedState, currentState) => {2705 merge: (persistedState, currentState) => {
2141 const normalizedPersisted =2706 const normalizedPersisted =
2142 normalizeCurrentPersistedComposerDraftStoreState(persistedState);2707 normalizeCurrentPersistedComposerDraftStoreState(persistedState);
2143 const draftsByThreadId = Object.fromEntries(2708 const draftsByThreadKey = Object.fromEntries(
2144 Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [2709 Object.entries(normalizedPersisted.draftsByThreadKey).map(([threadKey, draft]) => [
2145 threadId,2710 threadKey,
2146 toHydratedThreadDraft(draft),2711 toHydratedThreadDraft(draft),
2147 ]),2712 ]),
2148 );2713 );
2714 const draftThreadsByThreadKey = Object.fromEntries(
2715 Object.entries(normalizedPersisted.draftThreadsByThreadKey).map(
2716 ([threadKey, draftThread]) => [threadKey, toHydratedDraftThreadState(draftThread)],
2717 ),
2718 ) as Record<string, DraftThreadState>;
2149 return {2719 return {
2150 ...currentState,2720 ...currentState,
2151 draftsByThreadId,2721 draftsByThreadKey,
2152 draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId,2722 draftThreadsByThreadKey,
2153 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,2723 logicalProjectDraftThreadKeyByLogicalProjectKey:
2724 normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey,
2154 stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {},2725 stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {},
2155 stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null,2726 stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null,
2156 };2727 };
@@ -2159,19 +2730,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( · 2 unmodified lines
2159 ),2730 ),
2160);2731);
2161 2732
2162export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState {2733export const useComposerDraftStore = composerDraftStore;
2163 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);2734
2735export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState {
2736 return useComposerDraftStore((state) => {
2737 return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT;
2738 });
2164}2739}
2165 2740
2166export function useEffectiveComposerModelState(input: {2741export function useEffectiveComposerModelState(input: {
2167 threadId: ThreadId;2742 threadRef?: ComposerThreadTarget;
2743 draftId?: DraftId;
2168 providers: ReadonlyArray<ServerProvider>;2744 providers: ReadonlyArray<ServerProvider>;
2169 selectedProvider: ProviderKind;2745 selectedProvider: ProviderKind;
2170 threadModelSelection: ModelSelection | null | undefined;2746 threadModelSelection: ModelSelection | null | undefined;
2171 projectModelSelection: ModelSelection | null | undefined;2747 projectModelSelection: ModelSelection | null | undefined;
2172 settings: UnifiedSettings;2748 settings: UnifiedSettings;
2173}): EffectiveComposerModelState {2749}): EffectiveComposerModelState {
2174 const draft = useComposerThreadDraft(input.threadId);2750 const draft = useComposerThreadDraft(input.threadRef ?? input.draftId ?? DraftId.makeUnsafe(""));
2175 2751
2176 return useMemo(2752 return useMemo(
2177 () =>2753 () =>
@@ -2195,21 +2771,69 @@ export function useEffectiveComposerModelState(input: { · 17 unmodified lines
2195}2771}
2196 2772
2197/**2773/**
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.
2199 *2775 *
2200 * Use the single-thread helper for live `thread.created` events and the2776 * Use the single-thread helper for live `thread.created` events and the
2201 * iterable helper for bootstrap/recovery paths that discover multiple server2777 * iterable helper for bootstrap/recovery paths that discover multiple server
2202 * threads at once.2778 * threads at once.
2203 */2779 */
2204export function clearPromotedDraftThread(threadId: ThreadId): void {2780export function markPromotedDraftThread(threadId: ThreadId): void {
2205 if (!useComposerDraftStore.getState().getDraftThread(threadId)) {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) {
2206 return;2789 return;
2207 }2790 }
2208 useComposerDraftStore.getState().clearDraftThread(threadId);2791 for (const draftThreadTarget of draftThreadTargets) {
2792 store.markDraftThreadPromoting(draftThreadTarget);
2793 }
2794}
2795
2796export 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 }
2209}2806}
2210 2807
2211export function clearPromotedDraftThreads(serverThreadIds: Iterable<ThreadId>): void {2808export function markPromotedDraftThreads(serverThreadIds: Iterable<ThreadId>): void {
2212 for (const threadId of serverThreadIds) {2809 for (const threadId of serverThreadIds) {
2213 clearPromotedDraftThread(threadId);2810 markPromotedDraftThread(threadId);
2811 }
2812}
2813
2814export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable<ScopedThreadRef>): void {
2815 for (const threadRef of serverThreadRefs) {
2816 markPromotedDraftThreadByRef(threadRef);
2817 }
2818}
2819
2820export 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
2833export function finalizePromotedDraftThreadsByRef(
2834 serverThreadRefs: Iterable<ScopedThreadRef>,
2835): void {
2836 for (const threadRef of serverThreadRefs) {
2837 finalizePromotedDraftThreadByRef(threadRef);
2214 }2838 }
2215}2839}
apps/web/src/editorPreferences.ts+2−2
@@ -1,4 +1,4 @@
1import { EDITORS, EditorId, NativeApi } from "@t3tools/contracts";1import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts";
2import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage";2import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage";
3import { useMemo } from "react";3import { useMemo } from "react";
4 4
@@ -26,7 +26,7 @@ export function resolveAndPersistPreferredEditor( · 21 unmodified lines
26 return editor ?? null;26 return editor ?? null;
27}27}
28 28
29export async function openInPreferredEditor(api: NativeApi, targetPath: string): Promise<EditorId> {29export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise<EditorId> {
30 const { availableEditors } = await api.server.getConfig();30 const { availableEditors } = await api.server.getConfig();
31 const editor = resolveAndPersistPreferredEditor(availableEditors);31 const editor = resolveAndPersistPreferredEditor(availableEditors);
32 if (!editor) throw new Error("No available editors found.");32 if (!editor) throw new Error("No available editors found.");
apps/web/src/environmentApi.ts+67−0
@@ -0,0 +1,67 @@
1import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts";
2
3import { readWsRpcClientEntryForEnvironment, WsRpcClient } from "./wsRpcClient";
4
5export 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
48export 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
61export 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 @@
1import {
2 createKnownEnvironmentFromWsUrl,
3 getKnownEnvironmentBaseUrl,
4 type KnownEnvironment,
5} from "@t3tools/client-runtime";
6import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts";
7
8function 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
23export 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
59export 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 @@
1import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";1import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";
2import { useNavigate, useParams } from "@tanstack/react-router";2import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts";
3import { useParams, useRouter } from "@tanstack/react-router";
3import { useCallback, useMemo } from "react";4import { useCallback, useMemo } from "react";
4import { useShallow } from "zustand/react/shallow";5import { useShallow } from "zustand/react/shallow";
5import {6import {
@@ -7,37 +8,49 @@ import { · 1 unmodified line
7 type DraftThreadState,8 type DraftThreadState,
8 useComposerDraftStore,9 useComposerDraftStore,
9} from "../composerDraftStore";10} from "../composerDraftStore";
10import { newThreadId } from "../lib/utils";11import { newDraftId, newThreadId } from "../lib/utils";
11import { orderItemsByPreferredIds } from "../components/Sidebar.logic";12import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
12import { useStore } from "../store";13import { deriveLogicalProjectKey } from "../logicalProject";
13import { createThreadSelector } from "../storeSelectors";14import { selectProjectsAcrossEnvironments, useStore } from "../store";
15import { createThreadSelectorByRef } from "../storeSelectors";
16import { resolveThreadRouteTarget } from "../threadRoutes";
14import { useUiStateStore } from "../uiStateStore";17import { useUiStateStore } from "../uiStateStore";
15 18
16export function useHandleNewThread() {19export function useHandleNewThread() {
17 const projectIds = useStore(useShallow((store) => store.projectIds));20 const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store)));
18 const projectOrder = useUiStateStore((store) => store.projectOrder);21 const projectOrder = useUiStateStore((store) => store.projectOrder);
19 const navigate = useNavigate();22 const router = useRouter();
20 const routeThreadId = useParams({23 const routeTarget = useParams({
21 strict: false,24 strict: false,
22 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),25 select: (params) => resolveThreadRouteTarget(params),
23 });26 });
27 const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
24 const activeThread = useStore(28 const activeThread = useStore(
25 useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]),29 useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]),
26 );30 );
27 const activeDraftThread = useComposerDraftStore((store) =>31 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
28 routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null,32 const activeDraftThread = useComposerDraftStore(() =>
33 routeTarget
34 ? routeTarget.kind === "server"
35 ? getDraftThread(routeTarget.threadRef)
36 : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId)
37 : null,
29 );38 );
30 const orderedProjects = useMemo(() => {39 const orderedProjects = useMemo(() => {
31 return orderItemsByPreferredIds({40 return orderItemsByPreferredIds({
32 items: projectIds,41 items: projects,
33 preferredIds: projectOrder,42 preferredIds: projectOrder,
34 getId: (projectId) => projectId,43 getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
35 });44 });
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]);
37 50
38 const handleNewThread = useCallback(51 const handleNewThread = useCallback(
39 (52 (
40 projectId: ProjectId,53 projectRef: ScopedProjectRef,
41 options?: {54 options?: {
42 branch?: string | null;55 branch?: string | null;
43 worktreePath?: string | null;56 worktreePath?: string | null;
@@ -45,84 +58,110 @@ export function useHandleNewThread() { · 1 unmodified line
45 },58 },
46 ): Promise<void> => {59 ): Promise<void> => {
47 const {60 const {
48 clearProjectDraftThreadId,61 getDraftSessionByLogicalProjectKey,
62 getDraftSession,
49 getDraftThread,63 getDraftThread,
50 getDraftThreadByProjectId,
51 applyStickyState,64 applyStickyState,
52 setDraftThreadContext,65 setDraftThreadContext,
53 setProjectDraftThreadId,66 setLogicalProjectDraftThreadId,
54 } = useComposerDraftStore.getState();67 } = 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);
55 const hasBranchOption = options?.branch !== undefined;77 const hasBranchOption = options?.branch !== undefined;
56 const hasWorktreePathOption = options?.worktreePath !== undefined;78 const hasWorktreePathOption = options?.worktreePath !== undefined;
57 const hasEnvModeOption = options?.envMode !== undefined;79 const hasEnvModeOption = options?.envMode !== undefined;
58 const storedDraftThread = getDraftThreadByProjectId(projectId);80 const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey);
59 const latestActiveDraftThread: DraftThreadState | null = routeThreadId81 const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget
60 ? getDraftThread(routeThreadId)82 ? currentRouteTarget.kind === "server"
83 ? getDraftThread(currentRouteTarget.threadRef)
84 : getDraftSession(currentRouteTarget.draftId)
61 : null;85 : null;
62 if (storedDraftThread) {86 if (storedDraftThread) {
63 return (async () => {87 return (async () => {
64 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {88 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
65 setDraftThreadContext(storedDraftThread.threadId, {89 setDraftThreadContext(storedDraftThread.draftId, {
66 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),90 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
67 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),91 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
68 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),92 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
69 });93 });
70 }94 }
71 setProjectDraftThreadId(projectId, storedDraftThread.threadId);95 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, storedDraftThread.draftId, {
72 if (routeThreadId === storedDraftThread.threadId) {96 threadId: storedDraftThread.threadId,
97 });
98 if (
99 currentRouteTarget?.kind === "draft" &&
100 currentRouteTarget.draftId === storedDraftThread.draftId
101 ) {
73 return;102 return;
74 }103 }
75 await navigate({104 await router.navigate({
76 to: "/$threadId",105 to: "/draft/$draftId",
77 params: { threadId: storedDraftThread.threadId },106 params: { draftId: storedDraftThread.draftId },
78 });107 });
79 })();108 })();
80 }109 }
81 110
82 clearProjectDraftThreadId(projectId);
83
84 if (111 if (
85 latestActiveDraftThread &&112 latestActiveDraftThread &&
86 routeThreadId &&113 currentRouteTarget?.kind === "draft" &&
87 latestActiveDraftThread.projectId === projectId114 latestActiveDraftThread.logicalProjectKey === logicalProjectKey
88 ) {115 ) {
89 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {116 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
90 setDraftThreadContext(routeThreadId, {117 setDraftThreadContext(currentRouteTarget.draftId, {
91 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),118 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
92 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),119 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
93 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),120 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
94 });121 });
95 }122 }
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 });
97 return Promise.resolve();132 return Promise.resolve();
98 }133 }
99 134
135 const draftId = newDraftId();
100 const threadId = newThreadId();136 const threadId = newThreadId();
101 const createdAt = new Date().toISOString();137 const createdAt = new Date().toISOString();
102 return (async () => {138 return (async () => {
103 setProjectDraftThreadId(projectId, threadId, {139 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
140 threadId,
104 createdAt,141 createdAt,
105 branch: options?.branch ?? null,142 branch: options?.branch ?? null,
106 worktreePath: options?.worktreePath ?? null,143 worktreePath: options?.worktreePath ?? null,
107 envMode: options?.envMode ?? "local",144 envMode: options?.envMode ?? "local",
108 runtimeMode: DEFAULT_RUNTIME_MODE,145 runtimeMode: DEFAULT_RUNTIME_MODE,
109 });146 });
110 applyStickyState(threadId);147 applyStickyState(draftId);
111 148
112 await navigate({149 await router.navigate({
113 to: "/$threadId",150 to: "/draft/$draftId",
114 params: { threadId },151 params: { draftId },
115 });152 });
116 })();153 })();
117 },154 },
118 [navigate, routeThreadId],155 [getCurrentRouteTarget, router, projects],
119 );156 );
120 157
121 return {158 return {
122 activeDraftThread,159 activeDraftThread,
123 activeThread,160 activeThread,
124 defaultProjectId: orderedProjects[0] ?? null,161 defaultProjectRef: orderedProjects[0]
162 ? scopeProjectRef(orderedProjects[0].environmentId, orderedProjects[0].id)
163 : null,
125 handleNewThread,164 handleNewThread,
126 routeThreadId,165 routeThreadRef,
127 };166 };
128}167}
apps/web/src/hooks/useSettings.ts+4−6
@@ -26,7 +26,7 @@ import { · 25 unmodified lines
26 TimestampFormat,26 TimestampFormat,
27 UnifiedSettings,27 UnifiedSettings,
28} from "@t3tools/contracts/settings";28} from "@t3tools/contracts/settings";
29import { ensureNativeApi } from "~/nativeApi";29import { ensureLocalApi } from "~/localApi";
30import { useLocalStorage } from "./useLocalStorage";30import { useLocalStorage } from "./useLocalStorage";
31import { normalizeCustomModelSlugs } from "~/modelSelection";31import { normalizeCustomModelSlugs } from "~/modelSelection";
32import { Predicate, Schema, Struct } from "effect";32import { Predicate, Schema, Struct } from "effect";
@@ -67,9 +67,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): { · 34 unmodified lines
67 * only re-render when the slice they care about changes.67 * only re-render when the slice they care about changes.
68 */68 */
69 69
70export function useSettings<T extends UnifiedSettings = UnifiedSettings>(70export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
71 selector?: (s: UnifiedSettings) => T,
72): T {
73 const serverSettings = useServerSettings();71 const serverSettings = useServerSettings();
74 const [clientSettings] = useLocalStorage(72 const [clientSettings] = useLocalStorage(
75 CLIENT_SETTINGS_STORAGE_KEY,73 CLIENT_SETTINGS_STORAGE_KEY,
@@ -111,7 +109,7 @@ export function useUpdateSettings() { · 35 unmodified lines
111 applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch));109 applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch));
112 }110 }
113 // Fire-and-forget RPC — push will reconcile on success111 // Fire-and-forget RPC — push will reconcile on success
114 void ensureNativeApi().server.updateSettings(serverPatch);112 void ensureLocalApi().server.updateSettings(serverPatch);
115 }113 }
116 114
117 if (Object.keys(clientPatch).length > 0) {115 if (Object.keys(clientPatch).length > 0) {
@@ -239,7 +237,7 @@ export function migrateLocalSettingsToServer(): void { · 121 unmodified lines
239 // Migrate server-relevant keys via RPC237 // Migrate server-relevant keys via RPC
240 const serverPatch = buildLegacyServerSettingsMigrationPatch(old);238 const serverPatch = buildLegacyServerSettingsMigrationPatch(old);
241 if (Object.keys(serverPatch).length > 0) {239 if (Object.keys(serverPatch).length > 0) {
242 const api = ensureNativeApi();240 const api = ensureLocalApi();
243 void api.server.updateSettings(serverPatch);241 void api.server.updateSettings(serverPatch);
244 }242 }
245 243
apps/web/src/hooks/useThreadActions.ts+128−64
@@ -1,42 +1,63 @@
1import { ThreadId } from "@t3tools/contracts";1import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2import { useMutation, useQueryClient } from "@tanstack/react-query";2import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
3import { useNavigate, useParams } from "@tanstack/react-router";3import { useQueryClient } from "@tanstack/react-query";
4import { useRouter } from "@tanstack/react-router";
4import { useCallback } from "react";5import { useCallback } from "react";
5 6
6import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";7import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
7import { useComposerDraftStore } from "../composerDraftStore";8import { useComposerDraftStore } from "../composerDraftStore";
8import { useHandleNewThread } from "./useHandleNewThread";9import { useHandleNewThread } from "./useHandleNewThread";
9import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";10import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";
11import { invalidateGitQueries } from "../lib/gitReactQuery";
10import { newCommandId } from "../lib/utils";12import { newCommandId } from "../lib/utils";
11import { readNativeApi } from "../nativeApi";13import { readLocalApi } from "../localApi";
12import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store";14import {
15 selectProjectByRef,
16 selectThreadByRef,
17 selectThreadsForEnvironment,
18 useStore,
19} from "../store";
13import { useTerminalStateStore } from "../terminalStateStore";20import { useTerminalStateStore } from "../terminalStateStore";
21import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
14import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";22import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
15import { toastManager } from "../components/ui/toast";23import { toastManager } from "../components/ui/toast";
16import { useSettings } from "./useSettings";24import { useSettings } from "./useSettings";
17 25
18export function useThreadActions() {26export function useThreadActions() {
19 const appSettings = useSettings();27 const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder);
28 const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete);
20 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);29 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
21 const clearProjectDraftThreadById = useComposerDraftStore(30 const clearProjectDraftThreadById = useComposerDraftStore(
22 (store) => store.clearProjectDraftThreadById,31 (store) => store.clearProjectDraftThreadById,
23 );32 );
24 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);33 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
25 const routeThreadId = useParams({34 const router = useRouter();
26 strict: false,
27 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
28 });
29 const navigate = useNavigate();
30 const { handleNewThread } = useHandleNewThread();35 const { handleNewThread } = useHandleNewThread();
31 const queryClient = useQueryClient();36 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]);
33 53
34 const archiveThread = useCallback(54 const archiveThread = useCallback(
35 async (threadId: ThreadId) => {55 async (target: ScopedThreadRef) => {
36 const api = readNativeApi();56 const api = readEnvironmentApi(target.environmentId);
37 if (!api) return;57 if (!api) return;
38 const thread = selectThreadById(threadId)(useStore.getState());58 const resolved = resolveThreadTarget(target);
39 if (!thread) return;59 if (!resolved) return;
60 const { thread, threadRef } = resolved;
40 if (thread.session?.status === "running" && thread.session.activeTurnId != null) {61 if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
41 throw new Error("Cannot archive a running thread.");62 throw new Error("Cannot archive a running thread.");
42 }63 }
@@ -44,48 +65,69 @@ export function useThreadActions() { · 1 unmodified line
44 await api.orchestration.dispatchCommand({65 await api.orchestration.dispatchCommand({
45 type: "thread.archive",66 type: "thread.archive",
46 commandId: newCommandId(),67 commandId: newCommandId(),
47 threadId,68 threadId: threadRef.threadId,
48 });69 });
70 const currentRouteThreadRef = getCurrentRouteThreadRef();
49 71
50 if (routeThreadId === threadId) {72 if (
51 await handleNewThread(thread.projectId);73 currentRouteThreadRef?.threadId === threadRef.threadId &&
74 currentRouteThreadRef.environmentId === threadRef.environmentId
75 ) {
76 await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId));
52 }77 }
53 },78 },
54 [handleNewThread, routeThreadId],79 [getCurrentRouteThreadRef, handleNewThread, resolveThreadTarget],
55 );80 );
56 81
57 const unarchiveThread = useCallback(async (threadId: ThreadId) => {82 const unarchiveThread = useCallback(async (target: ScopedThreadRef) => {
58 const api = readNativeApi();83 const api = readEnvironmentApi(target.environmentId);
59 if (!api) return;84 if (!api) return;
60 await api.orchestration.dispatchCommand({85 await api.orchestration.dispatchCommand({
61 type: "thread.unarchive",86 type: "thread.unarchive",
62 commandId: newCommandId(),87 commandId: newCommandId(),
63 threadId,88 threadId: target.threadId,
64 });89 });
65 }, []);90 }, []);
66 91
67 const deleteThread = useCallback(92 const deleteThread = useCallback(
68 async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => {93 async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => {
69 const api = readNativeApi();94 const api = readEnvironmentApi(target.environmentId);
70 if (!api) return;95 if (!api) return;
96 const resolved = resolveThreadTarget(target);
97 if (!resolved) return;
98 const { thread, threadRef } = resolved;
71 const state = useStore.getState();99 const state = useStore.getState();
72 const threads = selectThreads(state);100 const threads = selectThreadsForEnvironment(state, threadRef.environmentId);
73 const thread = selectThreadById(threadId)(state);101 const threadProject = selectProjectByRef(state, {
74 if (!thread) return;102 environmentId: threadRef.environmentId,
75 const threadProject = selectProjectById(thread.projectId)(state);103 projectId: thread.projectId,
76 const deletedIds = opts.deletedThreadIds;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;
77 const survivingThreads =114 const survivingThreads =
78 deletedIds && deletedIds.size > 0115 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))
80 : threads;117 : threads;
81 const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId);118 const orphanedWorktreePath = getOrphanedWorktreePathForThread(
119 survivingThreads,
120 threadRef.threadId,
121 );
82 const displayWorktreePath = orphanedWorktreePath122 const displayWorktreePath = orphanedWorktreePath
83 ? formatWorktreePathForDisplay(orphanedWorktreePath)123 ? formatWorktreePathForDisplay(orphanedWorktreePath)
84 : null;124 : null;
85 const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined;125 const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined;
126 const localApi = readLocalApi();
86 const shouldDeleteWorktree =127 const shouldDeleteWorktree =
87 canDeleteWorktree &&128 canDeleteWorktree &&
88 (await api.dialogs.confirm(129 localApi &&
130 (await localApi.dialogs.confirm(
89 [131 [
90 "This thread is the only one linked to this worktree:",132 "This thread is the only one linked to this worktree:",
91 displayWorktreePath ?? orphanedWorktreePath,133 displayWorktreePath ?? orphanedWorktreePath,
@@ -99,44 +141,60 @@ export function useThreadActions() { · 7 unmodified lines
99 .dispatchCommand({141 .dispatchCommand({
100 type: "thread.session.stop",142 type: "thread.session.stop",
101 commandId: newCommandId(),143 commandId: newCommandId(),
102 threadId,144 threadId: threadRef.threadId,
103 createdAt: new Date().toISOString(),145 createdAt: new Date().toISOString(),
104 })146 })
105 .catch(() => undefined);147 .catch(() => undefined);
106 }148 }
107 149
108 try {150 try {
109 await api.terminal.close({ threadId, deleteHistory: true });151 await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true });
110 } catch {152 } catch {
111 // Terminal may already be closed.153 // Terminal may already be closed.
112 }154 }
113 155
114 const deletedThreadIds = opts.deletedThreadIds ?? new Set<ThreadId>();156 const deletedThreadIds = deletedIds ?? new Set<ThreadId>();
115 const shouldNavigateToFallback = routeThreadId === threadId;157 const currentRouteThreadRef = getCurrentRouteThreadRef();
158 const shouldNavigateToFallback =
159 currentRouteThreadRef?.threadId === threadRef.threadId &&
160 currentRouteThreadRef.environmentId === threadRef.environmentId;
116 const fallbackThreadId = getFallbackThreadIdAfterDelete({161 const fallbackThreadId = getFallbackThreadIdAfterDelete({
117 threads,162 threads,
118 deletedThreadId: threadId,163 deletedThreadId: threadRef.threadId,
119 deletedThreadIds,164 deletedThreadIds,
120 sortOrder: appSettings.sidebarThreadSortOrder,165 sortOrder: sidebarThreadSortOrder,
121 });166 });
122 await api.orchestration.dispatchCommand({167 await api.orchestration.dispatchCommand({
123 type: "thread.delete",168 type: "thread.delete",
124 commandId: newCommandId(),169 commandId: newCommandId(),
125 threadId,170 threadId: threadRef.threadId,
126 });171 });
127 clearComposerDraftForThread(threadId);172 clearComposerDraftForThread(threadRef);
128 clearProjectDraftThreadById(thread.projectId, thread.id);173 clearProjectDraftThreadById(
129 clearTerminalState(threadId);174 scopeProjectRef(threadRef.environmentId, thread.projectId),
175 threadRef,
176 );
177 clearTerminalState(threadRef);
130 178
131 if (shouldNavigateToFallback) {179 if (shouldNavigateToFallback) {
132 if (fallbackThreadId) {180 if (fallbackThreadId) {
133 await navigate({181 const fallbackThread = selectThreadByRef(
134 to: "/$threadId",182 useStore.getState(),
135 params: { threadId: fallbackThreadId },183 scopeThreadRef(threadRef.environmentId, fallbackThreadId),
136 replace: true,184 );
137 });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 }
138 } else {196 } else {
139 await navigate({ to: "/", replace: true });197 await router.navigate({ to: "/", replace: true });
140 }198 }
141 }199 }
142 200
@@ -145,15 +203,18 @@ export function useThreadActions() { · 2 unmodified lines
145 }203 }
146 204
147 try {205 try {
148 await removeWorktreeMutation.mutateAsync({206 await ensureEnvironmentApi(threadRef.environmentId).git.removeWorktree({
149 cwd: threadProject.cwd,207 cwd: threadProject.cwd,
150 path: orphanedWorktreePath,208 path: orphanedWorktreePath,
151 force: true,209 force: true,
152 });210 });
211 await invalidateGitQueries(queryClient, {
212 environmentId: threadRef.environmentId,
213 });
153 } catch (error) {214 } catch (error) {
154 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";215 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";
155 console.error("Failed to remove orphaned worktree after thread deletion", {216 console.error("Failed to remove orphaned worktree after thread deletion", {
156 threadId,217 threadId: threadRef.threadId,
157 projectCwd: threadProject.cwd,218 projectCwd: threadProject.cwd,
158 worktreePath: orphanedWorktreePath,219 worktreePath: orphanedWorktreePath,
159 error,220 error,
@@ -169,22 +230,25 @@ export function useThreadActions() { · 9 unmodified lines
169 clearComposerDraftForThread,230 clearComposerDraftForThread,
170 clearProjectDraftThreadById,231 clearProjectDraftThreadById,
171 clearTerminalState,232 clearTerminalState,
172 appSettings.sidebarThreadSortOrder,233 getCurrentRouteThreadRef,
173 navigate,234 router,
174 removeWorktreeMutation,235 queryClient,
175 routeThreadId,236 resolveThreadTarget,
237 sidebarThreadSortOrder,
176 ],238 ],
177 );239 );
178 240
179 const confirmAndDeleteThread = useCallback(241 const confirmAndDeleteThread = useCallback(
180 async (threadId: ThreadId) => {242 async (target: ScopedThreadRef) => {
181 const api = readNativeApi();243 const api = readEnvironmentApi(target.environmentId);
182 if (!api) return;244 if (!api) return;
183 const thread = selectThreadById(threadId)(useStore.getState());245 const localApi = readLocalApi();
184 if (!thread) return;246 const resolved = resolveThreadTarget(target);
247 if (!resolved) return;
248 const { thread } = resolved;
185 249
186 if (appSettings.confirmThreadDelete) {250 if (confirmThreadDelete && localApi) {
187 const confirmed = await api.dialogs.confirm(251 const confirmed = await localApi.dialogs.confirm(
188 [252 [
189 `Delete thread "${thread.title}"?`,253 `Delete thread "${thread.title}"?`,
190 "This permanently clears conversation history for this thread.",254 "This permanently clears conversation history for this thread.",
@@ -195,9 +259,9 @@ export function useThreadActions() { · 4 unmodified lines
195 }259 }
196 }260 }
197 261
198 await deleteThread(threadId);262 await deleteThread(target);
199 },263 },
200 [appSettings.confirmThreadDelete, deleteThread],264 [confirmThreadDelete, deleteThread, resolveThreadTarget],
201 );265 );
202 266
203 return {267 return {
apps/web/src/lib/gitReactQuery.test.ts+30−13
@@ -1,16 +1,17 @@
1import { QueryClient } from "@tanstack/react-query";1import { QueryClient } from "@tanstack/react-query";
2import { describe, expect, it, vi } from "vitest";2import { describe, expect, it, vi } from "vitest";
3 3
4vi.mock("../nativeApi", () => ({4vi.mock("../environmentApi", () => ({
5 ensureNativeApi: vi.fn(),5 ensureEnvironmentApi: vi.fn(),
6}));6}));
7 7
8vi.mock("../wsRpcClient", () => ({8vi.mock("../wsRpcClient", () => ({
9 getWsRpcClient: vi.fn(),9 getWsRpcClient: vi.fn(),
10 getWsRpcClientForEnvironment: vi.fn(),
10}));11}));
11 12
12import type { InfiniteData } from "@tanstack/react-query";13import type { InfiniteData } from "@tanstack/react-query";
13import type { GitListBranchesResult } from "@t3tools/contracts";14import { EnvironmentId, type GitListBranchesResult } from "@t3tools/contracts";
14 15
15import {16import {
16 gitBranchSearchInfiniteQueryOptions,17 gitBranchSearchInfiniteQueryOptions,
@@ -33,21 +34,25 @@ const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = { · 16 unmodified lines
33 pages: [BRANCH_QUERY_RESULT],34 pages: [BRANCH_QUERY_RESULT],
34 pageParams: [0],35 pageParams: [0],
35};36};
37const ENVIRONMENT_A = EnvironmentId.makeUnsafe("environment-a");
38const ENVIRONMENT_B = EnvironmentId.makeUnsafe("environment-b");
36 39
37describe("gitMutationKeys", () => {40describe("gitMutationKeys", () => {
38 it("scopes stacked action keys by cwd", () => {41 it("scopes stacked action keys by cwd", () => {
39 expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual(42 expect(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")).not.toEqual(
40 gitMutationKeys.runStackedAction("/repo/b"),43 gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/b"),
41 );44 );
42 });45 });
43 46
44 it("scopes pull keys by cwd", () => {47 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 );
46 });51 });
47 52
48 it("scopes pull request thread preparation keys by cwd", () => {53 it("scopes pull request thread preparation keys by cwd", () => {
49 expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual(54 expect(gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a")).not.toEqual(
50 gitMutationKeys.preparePullRequestThread("/repo/b"),55 gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/b"),
51 );56 );
52 });57 });
53});58});
@@ -57,23 +62,31 @@ describe("git mutation options", () => { · 3 unmodified lines
57 62
58 it("attaches cwd-scoped mutation key for runStackedAction", () => {63 it("attaches cwd-scoped mutation key for runStackedAction", () => {
59 const options = gitRunStackedActionMutationOptions({64 const options = gitRunStackedActionMutationOptions({
65 environmentId: ENVIRONMENT_A,
60 cwd: "/repo/a",66 cwd: "/repo/a",
61 queryClient,67 queryClient,
62 });68 });
63 expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a"));69 expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a"));
64 });70 });
65 71
66 it("attaches cwd-scoped mutation key for pull", () => {72 it("attaches cwd-scoped mutation key for pull", () => {
67 const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });73 const options = gitPullMutationOptions({
68 expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));74 environmentId: ENVIRONMENT_A,
75 cwd: "/repo/a",
76 queryClient,
77 });
78 expect(options.mutationKey).toEqual(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a"));
69 });79 });
70 80
71 it("attaches cwd-scoped mutation key for preparePullRequestThread", () => {81 it("attaches cwd-scoped mutation key for preparePullRequestThread", () => {
72 const options = gitPreparePullRequestThreadMutationOptions({82 const options = gitPreparePullRequestThreadMutationOptions({
83 environmentId: ENVIRONMENT_A,
73 cwd: "/repo/a",84 cwd: "/repo/a",
74 queryClient,85 queryClient,
75 });86 });
76 expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a"));87 expect(options.mutationKey).toEqual(
88 gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a"),
89 );
77 });90 });
78});91});
79 92
@@ -83,6 +96,7 @@ describe("invalidateGitQueries", () => { · 3 unmodified lines
83 96
84 queryClient.setQueryData(97 queryClient.setQueryData(
85 gitBranchSearchInfiniteQueryOptions({98 gitBranchSearchInfiniteQueryOptions({
99 environmentId: ENVIRONMENT_A,
86 cwd: "/repo/a",100 cwd: "/repo/a",
87 query: "feature",101 query: "feature",
88 }).queryKey,102 }).queryKey,
@@ -90,17 +104,19 @@ describe("invalidateGitQueries", () => { · 1 unmodified line
90 );104 );
91 queryClient.setQueryData(105 queryClient.setQueryData(
92 gitBranchSearchInfiniteQueryOptions({106 gitBranchSearchInfiniteQueryOptions({
107 environmentId: ENVIRONMENT_B,
93 cwd: "/repo/b",108 cwd: "/repo/b",
94 query: "feature",109 query: "feature",
95 }).queryKey,110 }).queryKey,
96 BRANCH_SEARCH_RESULT,111 BRANCH_SEARCH_RESULT,
97 );112 );
98 113
99 await invalidateGitQueries(queryClient, { cwd: "/repo/a" });114 await invalidateGitQueries(queryClient, { environmentId: ENVIRONMENT_A, cwd: "/repo/a" });
100 115
101 expect(116 expect(
102 queryClient.getQueryState(117 queryClient.getQueryState(
103 gitBranchSearchInfiniteQueryOptions({118 gitBranchSearchInfiniteQueryOptions({
119 environmentId: ENVIRONMENT_A,
104 cwd: "/repo/a",120 cwd: "/repo/a",
105 query: "feature",121 query: "feature",
106 }).queryKey,122 }).queryKey,
@@ -109,6 +125,7 @@ describe("invalidateGitQueries", () => { · 2 unmodified lines
109 expect(125 expect(
110 queryClient.getQueryState(126 queryClient.getQueryState(
111 gitBranchSearchInfiniteQueryOptions({127 gitBranchSearchInfiniteQueryOptions({
128 environmentId: ENVIRONMENT_B,
112 cwd: "/repo/b",129 cwd: "/repo/b",
113 query: "feature",130 query: "feature",
114 }).queryKey,131 }).queryKey,
apps/web/src/lib/gitReactQuery.ts+105−53
@@ -1,4 +1,5 @@
1import {1import {
2 type EnvironmentId,
2 type GitActionProgressEvent,3 type GitActionProgressEvent,
3 type GitStackedAction,4 type GitStackedAction,
4 type ThreadId,5 type ThreadId,
@@ -9,8 +10,8 @@ import { · 4 unmodified lines
9 queryOptions,10 queryOptions,
10 type QueryClient,11 type QueryClient,
11} from "@tanstack/react-query";12} from "@tanstack/react-query";
12import { ensureNativeApi } from "../nativeApi";13import { ensureEnvironmentApi } from "../environmentApi";
13import { getWsRpcClient } from "../wsRpcClient";14import { getWsRpcClientForEnvironment } from "../wsRpcClient";
14 15
15const GIT_BRANCHES_STALE_TIME_MS = 15_000;16const GIT_BRANCHES_STALE_TIME_MS = 15_000;
16const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;17const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
@@ -18,38 +19,52 @@ const GIT_BRANCHES_PAGE_SIZE = 100; · 1 unmodified line
18 19
19export const gitQueryKeys = {20export const gitQueryKeys = {
20 all: ["git"] as const,21 all: ["git"] as const,
21 branches: (cwd: string | null) => ["git", "branches", cwd] as const,22 branches: (environmentId: EnvironmentId | null, cwd: string | null) =>
22 branchSearch: (cwd: string | null, query: string) =>23 ["git", "branches", environmentId ?? null, cwd] as const,
23 ["git", "branches", cwd, "search", query] as const,24 branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) =>
25 ["git", "branches", environmentId ?? null, cwd, "search", query] as const,
24};26};
25 27
26export const gitMutationKeys = {28export const gitMutationKeys = {
27 init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const,29 init: (environmentId: EnvironmentId | null, cwd: string | null) =>
28 checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const,30 ["git", "mutation", "init", environmentId ?? null, cwd] as const,
29 runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const,31 checkout: (environmentId: EnvironmentId | null, cwd: string | null) =>
30 pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,32 ["git", "mutation", "checkout", environmentId ?? null, cwd] as const,
31 preparePullRequestThread: (cwd: string | null) =>33 runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) =>
32 ["git", "mutation", "prepare-pull-request-thread", cwd] as const,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,
33};39};
34 40
35export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) {41export function invalidateGitQueries(
42 queryClient: QueryClient,
43 input?: { environmentId?: EnvironmentId | null; cwd?: string | null },
44) {
45 const environmentId = input?.environmentId ?? null;
36 const cwd = input?.cwd ?? null;46 const cwd = input?.cwd ?? null;
37 if (cwd !== null) {47 if (cwd !== null) {
38 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) });48 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) });
39 }49 }
40 50
41 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });51 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
42}52}
43 53
44function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null) {54function invalidateGitBranchQueries(
55 queryClient: QueryClient,
56 environmentId: EnvironmentId | null,
57 cwd: string | null,
58) {
45 if (cwd === null) {59 if (cwd === null) {
46 return Promise.resolve();60 return Promise.resolve();
47 }61 }
48 62
49 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) });63 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) });
50}64}
51 65
52export function gitBranchSearchInfiniteQueryOptions(input: {66export function gitBranchSearchInfiniteQueryOptions(input: {
67 environmentId: EnvironmentId | null;
53 cwd: string | null;68 cwd: string | null;
54 query: string;69 query: string;
55 enabled?: boolean;70 enabled?: boolean;
@@ -57,11 +72,12 @@ export function gitBranchSearchInfiniteQueryOptions(input: { · 1 unmodified line
57 const normalizedQuery = input.query.trim();72 const normalizedQuery = input.query.trim();
58 73
59 return infiniteQueryOptions({74 return infiniteQueryOptions({
60 queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery),75 queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery),
61 initialPageParam: 0,76 initialPageParam: 0,
62 queryFn: async ({ pageParam }) => {77 queryFn: async ({ pageParam }) => {
63 const api = ensureNativeApi();
64 if (!input.cwd) throw new Error("Git branches are unavailable.");78 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);
65 return api.git.listBranches({81 return api.git.listBranches({
66 cwd: input.cwd,82 cwd: input.cwd,
67 ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}),83 ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}),
@@ -79,62 +95,75 @@ export function gitBranchSearchInfiniteQueryOptions(input: { · 11 unmodified lines
79}95}
80 96
81export function gitResolvePullRequestQueryOptions(input: {97export function gitResolvePullRequestQueryOptions(input: {
98 environmentId: EnvironmentId | null;
82 cwd: string | null;99 cwd: string | null;
83 reference: string | null;100 reference: string | null;
84}) {101}) {
85 return queryOptions({102 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,
87 queryFn: async () => {110 queryFn: async () => {
88 const api = ensureNativeApi();111 if (!input.cwd || !input.reference || !input.environmentId) {
89 if (!input.cwd || !input.reference) {
90 throw new Error("Pull request lookup is unavailable.");112 throw new Error("Pull request lookup is unavailable.");
91 }113 }
114 const api = ensureEnvironmentApi(input.environmentId);
92 return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference });115 return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference });
93 },116 },
94 enabled: input.cwd !== null && input.reference !== null,117 enabled: input.environmentId !== null && input.cwd !== null && input.reference !== null,
95 staleTime: 30_000,118 staleTime: 30_000,
96 refetchOnWindowFocus: false,119 refetchOnWindowFocus: false,
97 refetchOnReconnect: false,120 refetchOnReconnect: false,
98 });121 });
99}122}
100 123
101export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {124export function gitInitMutationOptions(input: {
125 environmentId: EnvironmentId | null;
126 cwd: string | null;
127 queryClient: QueryClient;
128}) {
102 return mutationOptions({129 return mutationOptions({
103 mutationKey: gitMutationKeys.init(input.cwd),130 mutationKey: gitMutationKeys.init(input.environmentId, input.cwd),
104 mutationFn: async () => {131 mutationFn: async () => {
105 const api = ensureNativeApi();132 if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable.");
106 if (!input.cwd) throw new Error("Git init is unavailable.");133 const api = ensureEnvironmentApi(input.environmentId);
107 return api.git.init({ cwd: input.cwd });134 return api.git.init({ cwd: input.cwd });
108 },135 },
109 onSettled: async () => {136 onSettled: async () => {
110 await invalidateGitBranchQueries(input.queryClient, input.cwd);137 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
111 },138 },
112 });139 });
113}140}
114 141
115export function gitCheckoutMutationOptions(input: {142export function gitCheckoutMutationOptions(input: {
143 environmentId: EnvironmentId | null;
116 cwd: string | null;144 cwd: string | null;
117 queryClient: QueryClient;145 queryClient: QueryClient;
118}) {146}) {
119 return mutationOptions({147 return mutationOptions({
120 mutationKey: gitMutationKeys.checkout(input.cwd),148 mutationKey: gitMutationKeys.checkout(input.environmentId, input.cwd),
121 mutationFn: async (branch: string) => {149 mutationFn: async (branch: string) => {
122 const api = ensureNativeApi();150 if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable.");
123 if (!input.cwd) throw new Error("Git checkout is unavailable.");151 const api = ensureEnvironmentApi(input.environmentId);
124 return api.git.checkout({ cwd: input.cwd, branch });152 return api.git.checkout({ cwd: input.cwd, branch });
125 },153 },
126 onSettled: async () => {154 onSettled: async () => {
127 await invalidateGitBranchQueries(input.queryClient, input.cwd);155 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
128 },156 },
129 });157 });
130}158}
131 159
132export function gitRunStackedActionMutationOptions(input: {160export function gitRunStackedActionMutationOptions(input: {
161 environmentId: EnvironmentId | null;
133 cwd: string | null;162 cwd: string | null;
134 queryClient: QueryClient;163 queryClient: QueryClient;
135}) {164}) {
136 return mutationOptions({165 return mutationOptions({
137 mutationKey: gitMutationKeys.runStackedAction(input.cwd),166 mutationKey: gitMutationKeys.runStackedAction(input.environmentId, input.cwd),
138 mutationFn: async ({167 mutationFn: async ({
139 actionId,168 actionId,
140 action,169 action,
@@ -150,8 +179,8 @@ export function gitRunStackedActionMutationOptions(input: { · 9 unmodified lines
150 filePaths?: string[];179 filePaths?: string[];
151 onProgress?: (event: GitActionProgressEvent) => void;180 onProgress?: (event: GitActionProgressEvent) => void;
152 }) => {181 }) => {
153 if (!input.cwd) throw new Error("Git action is unavailable.");182 if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable.");
154 return getWsRpcClient().git.runStackedAction(183 return getWsRpcClientForEnvironment(input.environmentId).git.runStackedAction(
155 {184 {
156 action,185 action,
157 actionId,186 actionId,
@@ -164,62 +193,85 @@ export function gitRunStackedActionMutationOptions(input: { · 6 unmodified lines
164 );193 );
165 },194 },
166 onSuccess: async () => {195 onSuccess: async () => {
167 await invalidateGitBranchQueries(input.queryClient, input.cwd);196 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
168 },197 },
169 });198 });
170}199}
171 200
172export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {201export function gitPullMutationOptions(input: {
202 environmentId: EnvironmentId | null;
203 cwd: string | null;
204 queryClient: QueryClient;
205}) {
173 return mutationOptions({206 return mutationOptions({
174 mutationKey: gitMutationKeys.pull(input.cwd),207 mutationKey: gitMutationKeys.pull(input.environmentId, input.cwd),
175 mutationFn: async () => {208 mutationFn: async () => {
176 const api = ensureNativeApi();209 if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable.");
177 if (!input.cwd) throw new Error("Git pull is unavailable.");210 const api = ensureEnvironmentApi(input.environmentId);
178 return api.git.pull({ cwd: input.cwd });211 return api.git.pull({ cwd: input.cwd });
179 },212 },
180 onSuccess: async () => {213 onSuccess: async () => {
181 await invalidateGitBranchQueries(input.queryClient, input.cwd);214 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
182 },215 },
183 });216 });
184}217}
185 218
186export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) {219export function gitCreateWorktreeMutationOptions(input: {
220 environmentId: EnvironmentId | null;
221 queryClient: QueryClient;
222}) {
187 return mutationOptions({223 return mutationOptions({
188 mutationKey: ["git", "mutation", "create-worktree"] as const,224 mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const,
189 mutationFn: (225 mutationFn: (
190 args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["createWorktree"]>[0],226 args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["createWorktree"]>[0],
191 ) => ensureNativeApi().git.createWorktree(args),227 ) => {
228 if (!input.environmentId) {
229 throw new Error("Worktree creation is unavailable.");
230 }
231 return ensureEnvironmentApi(input.environmentId).git.createWorktree(args);
232 },
192 onSuccess: async () => {233 onSuccess: async () => {
193 await invalidateGitQueries(input.queryClient);234 await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
194 },235 },
195 });236 });
196}237}
197 238
198export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) {239export function gitRemoveWorktreeMutationOptions(input: {
240 environmentId: EnvironmentId | null;
241 queryClient: QueryClient;
242}) {
199 return mutationOptions({243 return mutationOptions({
200 mutationKey: ["git", "mutation", "remove-worktree"] as const,244 mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const,
201 mutationFn: (245 mutationFn: (
202 args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["removeWorktree"]>[0],246 args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["removeWorktree"]>[0],
203 ) => ensureNativeApi().git.removeWorktree(args),247 ) => {
248 if (!input.environmentId) {
249 throw new Error("Worktree removal is unavailable.");
250 }
251 return ensureEnvironmentApi(input.environmentId).git.removeWorktree(args);
252 },
204 onSuccess: async () => {253 onSuccess: async () => {
205 await invalidateGitQueries(input.queryClient);254 await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
206 },255 },
207 });256 });
208}257}
209 258
210export function gitPreparePullRequestThreadMutationOptions(input: {259export function gitPreparePullRequestThreadMutationOptions(input: {
260 environmentId: EnvironmentId | null;
211 cwd: string | null;261 cwd: string | null;
212 queryClient: QueryClient;262 queryClient: QueryClient;
213}) {263}) {
214 return mutationOptions({264 return mutationOptions({
215 mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd),265 mutationKey: gitMutationKeys.preparePullRequestThread(input.environmentId, input.cwd),
216 mutationFn: async (args: {266 mutationFn: async (args: {
217 reference: string;267 reference: string;
218 mode: "local" | "worktree";268 mode: "local" | "worktree";
219 threadId?: ThreadId;269 threadId?: ThreadId;
220 }) => {270 }) => {
221 const api = ensureNativeApi();271 if (!input.cwd || !input.environmentId) {
222 if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");272 throw new Error("Pull request thread preparation is unavailable.");
273 }
274 const api = ensureEnvironmentApi(input.environmentId);
223 return api.git.preparePullRequestThread({275 return api.git.preparePullRequestThread({
224 cwd: input.cwd,276 cwd: input.cwd,
225 reference: args.reference,277 reference: args.reference,
@@ -228,7 +280,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: { · 2 unmodified lines
228 });280 });
229 },281 },
230 onSuccess: async () => {282 onSuccess: async () => {
231 await invalidateGitBranchQueries(input.queryClient, input.cwd);283 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
232 },284 },
233 });285 });
234}286}
apps/web/src/lib/gitStatusState.test.ts+47−9
@@ -1,4 +1,4 @@
1import type { GitStatusResult } from "@t3tools/contracts";1import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vitest";2import { afterEach, describe, expect, it, vi } from "vitest";
3 3
4import {4import {
@@ -16,6 +16,10 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even · 11 unmodified lines
16}16}
17 17
18const gitStatusListeners = new Set<(event: GitStatusResult) => void>();18const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
19const ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
20const OTHER_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-remote");
21const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const;
22const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const;
19 23
20const BASE_STATUS: GitStatusResult = {24const BASE_STATUS: GitStatusResult = {
21 isRepo: true,25 isRepo: true,
@@ -55,7 +59,7 @@ afterEach(() => { · 33 unmodified lines
55 59
56describe("gitStatusState", () => {60describe("gitStatusState", () => {
57 it("starts fresh cwd state in a pending state", () => {61 it("starts fresh cwd state in a pending state", () => {
58 expect(getGitStatusSnapshot("/fresh")).toEqual({62 expect(getGitStatusSnapshot(FRESH_TARGET)).toEqual({
59 data: null,63 data: null,
60 error: null,64 error: null,
61 cause: null,65 cause: null,
@@ -64,11 +68,11 @@ describe("gitStatusState", () => { · 2 unmodified lines
64 });68 });
65 69
66 it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => {70 it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => {
67 const releaseA = watchGitStatus("/repo", gitClient);71 const releaseA = watchGitStatus(TARGET, gitClient);
68 const releaseB = watchGitStatus("/repo", gitClient);72 const releaseB = watchGitStatus(TARGET, gitClient);
69 73
70 expect(gitClient.onStatus).toHaveBeenCalledOnce();74 expect(gitClient.onStatus).toHaveBeenCalledOnce();
71 expect(getGitStatusSnapshot("/repo")).toEqual({75 expect(getGitStatusSnapshot(TARGET)).toEqual({
72 data: null,76 data: null,
73 error: null,77 error: null,
74 cause: null,78 cause: null,
@@ -77,7 +81,7 @@ describe("gitStatusState", () => { · 2 unmodified lines
77 81
78 emitGitStatus(BASE_STATUS);82 emitGitStatus(BASE_STATUS);
79 83
80 expect(getGitStatusSnapshot("/repo")).toEqual({84 expect(getGitStatusSnapshot(TARGET)).toEqual({
81 data: BASE_STATUS,85 data: BASE_STATUS,
82 error: null,86 error: null,
83 cause: null,87 cause: null,
@@ -92,15 +96,15 @@ describe("gitStatusState", () => { · 8 unmodified lines
92 });96 });
93 97
94 it("refreshes git status through the unary RPC without restarting the stream", async () => {98 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);
96 100
97 emitGitStatus(BASE_STATUS);101 emitGitStatus(BASE_STATUS);
98 const refreshed = await refreshGitStatus("/repo", gitClient);102 const refreshed = await refreshGitStatus(TARGET, gitClient);
99 103
100 expect(gitClient.onStatus).toHaveBeenCalledOnce();104 expect(gitClient.onStatus).toHaveBeenCalledOnce();
101 expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });105 expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
102 expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" });106 expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" });
103 expect(getGitStatusSnapshot("/repo")).toEqual({107 expect(getGitStatusSnapshot(TARGET)).toEqual({
104 data: BASE_STATUS,108 data: BASE_STATUS,
105 error: null,109 error: null,
106 cause: null,110 cause: null,
@@ -109,4 +113,38 @@ describe("gitStatusState", () => { · 2 unmodified lines
109 113
110 release();114 release();
111 });115 });
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 });
112});150});
apps/web/src/lib/gitStatusState.ts+78−76
@@ -1,11 +1,15 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { type GitManagerServiceError, type GitStatusResult } from "@t3tools/contracts";2import {
3 type EnvironmentId,
4 type GitManagerServiceError,
5 type GitStatusResult,
6} from "@t3tools/contracts";
3import { Cause } from "effect";7import { Cause } from "effect";
4import { Atom } from "effect/unstable/reactivity";8import { Atom } from "effect/unstable/reactivity";
5import { useEffect } from "react";9import { useEffect } from "react";
6 10
7import { appAtomRegistry } from "../rpc/atomRegistry";11import { appAtomRegistry } from "../rpc/atomRegistry";
8import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient";12import { getWsRpcClient, getWsRpcClientForEnvironment, type WsRpcClient } from "../wsRpcClient";
9 13
10export type GitStatusStreamError = GitManagerServiceError;14export type GitStatusStreamError = GitManagerServiceError;
11 15
@@ -23,6 +27,11 @@ interface WatchedGitStatus { · 11 unmodified lines
23 unsubscribe: () => void;27 unsubscribe: () => void;
24}28}
25 29
30export interface GitStatusTarget {
31 readonly environmentId: EnvironmentId | null;
32 readonly cwd: string | null;
33}
34
26const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({35const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
27 data: null,36 data: null,
28 error: null,37 error: null,
@@ -40,79 +49,91 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( · 11 unmodified lines
40 49
41const NOOP: () => void = () => undefined;50const NOOP: () => void = () => undefined;
42const watchedGitStatuses = new Map<string, WatchedGitStatus>();51const watchedGitStatuses = new Map<string, WatchedGitStatus>();
43const knownGitStatusCwds = new Set<string>();52const knownGitStatusKeys = new Set<string>();
44const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>();53const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>();
45const gitStatusLastRefreshAtByCwd = new Map<string, number>();54const gitStatusLastRefreshAtByKey = new Map<string, number>();
46 55
47const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;56const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
48 57
49let sharedGitStatusClient: GitStatusClient | null = null;58const gitStatusStateAtom = Atom.family((key: string) => {
50 59 knownGitStatusKeys.add(key);
51const gitStatusStateAtom = Atom.family((cwd: string) => {
52 knownGitStatusCwds.add(cwd);
53 return Atom.make(INITIAL_GIT_STATUS_STATE).pipe(60 return Atom.make(INITIAL_GIT_STATUS_STATE).pipe(
54 Atom.keepAlive,61 Atom.keepAlive,
55 Atom.withLabel(`git-status:${cwd}`),62 Atom.withLabel(`git-status:${key}`),
56 );63 );
57});64});
58 65
59export function getGitStatusSnapshot(cwd: string | null): GitStatusState {66function getGitStatusTargetKey(target: GitStatusTarget): string | null {
60 if (cwd === null) {67 if (target.cwd === null) {
68 return null;
69 }
70
71 return `${target.environmentId ?? "__default__"}:${target.cwd}`;
72}
73
74function resolveGitStatusClient(target: GitStatusTarget): GitStatusClient {
75 if (target.environmentId) {
76 return getWsRpcClientForEnvironment(target.environmentId).git;
77 }
78 return getWsRpcClient().git;
79}
80
81export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState {
82 const targetKey = getGitStatusTargetKey(target);
83 if (targetKey === null) {
61 return EMPTY_GIT_STATUS_STATE;84 return EMPTY_GIT_STATUS_STATE;
62 }85 }
63 86
64 return appAtomRegistry.get(gitStatusStateAtom(cwd));87 return appAtomRegistry.get(gitStatusStateAtom(targetKey));
65}88}
66 89
67export function watchGitStatus(90export function watchGitStatus(
68 cwd: string | null,91 target: GitStatusTarget,
69 client: GitStatusClient = getWsRpcClient().git,92 client: GitStatusClient = resolveGitStatusClient(target),
70): () => void {93): () => void {
71 if (cwd === null) {94 const targetKey = getGitStatusTargetKey(target);
95 if (targetKey === null) {
72 return NOOP;96 return NOOP;
73 }97 }
74 98
75 ensureGitStatusClient(client);99 const watched = watchedGitStatuses.get(targetKey);
76
77 const watched = watchedGitStatuses.get(cwd);
78 if (watched) {100 if (watched) {
79 watched.refCount += 1;101 watched.refCount += 1;
80 return () => unwatchGitStatus(cwd);102 return () => unwatchGitStatus(targetKey);
81 }103 }
82 104
83 watchedGitStatuses.set(cwd, {105 watchedGitStatuses.set(targetKey, {
84 refCount: 1,106 refCount: 1,
85 unsubscribe: subscribeToGitStatus(cwd),107 unsubscribe: subscribeToGitStatus(targetKey, target.cwd!, client),
86 });108 });
87 109
88 return () => unwatchGitStatus(cwd);110 return () => unwatchGitStatus(targetKey);
89}111}
90 112
91export function refreshGitStatus(113export function refreshGitStatus(
92 cwd: string | null,114 target: GitStatusTarget,
93 client: GitStatusClient = getWsRpcClient().git,115 client: GitStatusClient = resolveGitStatusClient(target),
94): Promise<GitStatusResult | null> {116): Promise<GitStatusResult | null> {
95 if (cwd === null) {117 const targetKey = getGitStatusTargetKey(target);
118 if (targetKey === null || target.cwd === null) {
96 return Promise.resolve(null);119 return Promise.resolve(null);
97 }120 }
98 121
99 ensureGitStatusClient(client);122 const currentInFlight = gitStatusRefreshInFlight.get(targetKey);
100
101 const currentInFlight = gitStatusRefreshInFlight.get(cwd);
102 if (currentInFlight) {123 if (currentInFlight) {
103 return currentInFlight;124 return currentInFlight;
104 }125 }
105 126
106 const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0;127 const lastRequestedAt = gitStatusLastRefreshAtByKey.get(targetKey) ?? 0;
107 if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) {128 if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) {
108 return Promise.resolve(getGitStatusSnapshot(cwd).data);129 return Promise.resolve(getGitStatusSnapshot(target).data);
109 }130 }
110 131
111 gitStatusLastRefreshAtByCwd.set(cwd, Date.now());132 gitStatusLastRefreshAtByKey.set(targetKey, Date.now());
112 const refreshPromise = client.refreshStatus({ cwd }).finally(() => {133 const refreshPromise = client.refreshStatus({ cwd: target.cwd }).finally(() => {
113 gitStatusRefreshInFlight.delete(cwd);134 gitStatusRefreshInFlight.delete(targetKey);
114 });135 });
115 gitStatusRefreshInFlight.set(cwd, refreshPromise);136 gitStatusRefreshInFlight.set(targetKey, refreshPromise);
116 return refreshPromise;137 return refreshPromise;
117}138}
118 139
@@ -122,43 +143,29 @@ export function resetGitStatusStateForTests(): void { · 3 unmodified lines
122 }143 }
123 watchedGitStatuses.clear();144 watchedGitStatuses.clear();
124 gitStatusRefreshInFlight.clear();145 gitStatusRefreshInFlight.clear();
125 gitStatusLastRefreshAtByCwd.clear();146 gitStatusLastRefreshAtByKey.clear();
126 sharedGitStatusClient = null;
127 147
128 for (const cwd of knownGitStatusCwds) {148 for (const key of knownGitStatusKeys) {
129 appAtomRegistry.set(gitStatusStateAtom(cwd), INITIAL_GIT_STATUS_STATE);149 appAtomRegistry.set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE);
130 }150 }
131 knownGitStatusCwds.clear();151 knownGitStatusKeys.clear();
132}
133
134export 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;
139}152}
140 153
141function ensureGitStatusClient(client: GitStatusClient): void {154export function useGitStatus(target: GitStatusTarget): GitStatusState {
142 if (sharedGitStatusClient === client) {155 const targetKey = getGitStatusTargetKey(target);
143 return;156 useEffect(
144 }157 () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }),
145 158 [target.environmentId, target.cwd],
146 if (sharedGitStatusClient !== null) {159 );
147 resetLiveGitStatusSubscriptions();
148 }
149
150 sharedGitStatusClient = client;
151}
152 160
153function resetLiveGitStatusSubscriptions(): void {161 const state = useAtomValue(
154 for (const watched of watchedGitStatuses.values()) {162 targetKey !== null ? gitStatusStateAtom(targetKey) : EMPTY_GIT_STATUS_ATOM,
155 watched.unsubscribe();163 );
156 }164 return targetKey === null ? EMPTY_GIT_STATUS_STATE : state;
157 watchedGitStatuses.clear();
158}165}
159 166
160function unwatchGitStatus(cwd: string): void {167function unwatchGitStatus(targetKey: string): void {
161 const watched = watchedGitStatuses.get(cwd);168 const watched = watchedGitStatuses.get(targetKey);
162 if (!watched) {169 if (!watched) {
163 return;170 return;
164 }171 }
@@ -169,20 +176,15 @@ function unwatchGitStatus(cwd: string): void { · 4 unmodified lines
169 }176 }
170 177
171 watched.unsubscribe();178 watched.unsubscribe();
172 watchedGitStatuses.delete(cwd);179 watchedGitStatuses.delete(targetKey);
173}180}
174 181
175function subscribeToGitStatus(cwd: string): () => void {182function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void {
176 const client = sharedGitStatusClient;183 markGitStatusPending(targetKey);
177 if (!client) {
178 return NOOP;
179 }
180
181 markGitStatusPending(cwd);
182 return client.onStatus(184 return client.onStatus(
183 { cwd },185 { cwd },
184 (status) => {186 (status) => {
185 appAtomRegistry.set(gitStatusStateAtom(cwd), {187 appAtomRegistry.set(gitStatusStateAtom(targetKey), {
186 data: status,188 data: status,
187 error: null,189 error: null,
188 cause: null,190 cause: null,
@@ -191,14 +193,14 @@ function subscribeToGitStatus(cwd: string): () => void { · 2 unmodified lines
191 },193 },
192 {194 {
193 onResubscribe: () => {195 onResubscribe: () => {
194 markGitStatusPending(cwd);196 markGitStatusPending(targetKey);
195 },197 },
196 },198 },
197 );199 );
198}200}
199 201
200function markGitStatusPending(cwd: string): void {202function markGitStatusPending(targetKey: string): void {
201 const atom = gitStatusStateAtom(cwd);203 const atom = gitStatusStateAtom(targetKey);
202 const current = appAtomRegistry.get(atom);204 const current = appAtomRegistry.get(atom);
203 const next =205 const next =
204 current.data === null206 current.data === null
apps/web/src/lib/projectReactQuery.ts+17−8
@@ -1,11 +1,15 @@
1import type { ProjectSearchEntriesResult } from "@t3tools/contracts";1import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";2import { queryOptions } from "@tanstack/react-query";
3import { ensureNativeApi } from "~/nativeApi";3import { ensureEnvironmentApi } from "~/environmentApi";
4 4
5export const projectQueryKeys = {5export const projectQueryKeys = {
6 all: ["projects"] as const,6 all: ["projects"] as const,
7 searchEntries: (cwd: string | null, query: string, limit: number) =>7 searchEntries: (
8 ["projects", "search-entries", cwd, query, limit] as const,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,
9};13};
10 14
11const DEFAULT_SEARCH_ENTRIES_LIMIT = 80;15const DEFAULT_SEARCH_ENTRIES_LIMIT = 80;
@@ -16,6 +20,7 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { · 4 unmodified lines
16};20};
17 21
18export function projectSearchEntriesQueryOptions(input: {22export function projectSearchEntriesQueryOptions(input: {
23 environmentId: EnvironmentId | null;
19 cwd: string | null;24 cwd: string | null;
20 query: string;25 query: string;
21 enabled?: boolean;26 enabled?: boolean;
@@ -24,19 +29,23 @@ export function projectSearchEntriesQueryOptions(input: { · 2 unmodified lines
24}) {29}) {
25 const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT;30 const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT;
26 return queryOptions({31 return queryOptions({
27 queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit),32 queryKey: projectQueryKeys.searchEntries(input.environmentId, input.cwd, input.query, limit),
28 queryFn: async () => {33 queryFn: async () => {
29 const api = ensureNativeApi();34 if (!input.cwd || !input.environmentId) {
30 if (!input.cwd) {
31 throw new Error("Workspace entry search is unavailable.");35 throw new Error("Workspace entry search is unavailable.");
32 }36 }
37 const api = ensureEnvironmentApi(input.environmentId);
33 return api.projects.searchEntries({38 return api.projects.searchEntries({
34 cwd: input.cwd,39 cwd: input.cwd,
35 query: input.query,40 query: input.query,
36 limit,41 limit,
37 });42 });
38 },43 },
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,
40 staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,49 staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,
41 placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,50 placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,
42 });51 });
apps/web/src/lib/providerReactQuery.test.ts+11−4
@@ -1,21 +1,22 @@
1import { ThreadId, type NativeApi } from "@t3tools/contracts";1import { EnvironmentId, ThreadId, type EnvironmentApi } from "@t3tools/contracts";
2import { QueryClient } from "@tanstack/react-query";2import { QueryClient } from "@tanstack/react-query";
3import { afterEach, describe, expect, it, vi } from "vitest";3import { afterEach, describe, expect, it, vi } from "vitest";
4import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery";4import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery";
5import * as nativeApi from "../nativeApi";5import * as environmentApi from "../environmentApi";
6 6
7const threadId = ThreadId.makeUnsafe("thread-id");7const threadId = ThreadId.makeUnsafe("thread-id");
8const environmentId = EnvironmentId.makeUnsafe("environment-local");
8 9
9function mockNativeApi(input: {10function mockNativeApi(input: {
10 getTurnDiff: ReturnType<typeof vi.fn>;11 getTurnDiff: ReturnType<typeof vi.fn>;
11 getFullThreadDiff: ReturnType<typeof vi.fn>;12 getFullThreadDiff: ReturnType<typeof vi.fn>;
12}) {13}) {
13 vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({14 vi.spyOn(environmentApi, "ensureEnvironmentApi").mockReturnValue({
14 orchestration: {15 orchestration: {
15 getTurnDiff: input.getTurnDiff,16 getTurnDiff: input.getTurnDiff,
16 getFullThreadDiff: input.getFullThreadDiff,17 getFullThreadDiff: input.getFullThreadDiff,
17 },18 },
18 } as unknown as NativeApi);19 } as unknown as EnvironmentApi);
19}20}
20 21
21afterEach(() => {22afterEach(() => {
@@ -25,6 +26,7 @@ afterEach(() => { · 3 unmodified lines
25describe("providerQueryKeys.checkpointDiff", () => {26describe("providerQueryKeys.checkpointDiff", () => {
26 it("includes cacheScope so reused turn counts do not collide", () => {27 it("includes cacheScope so reused turn counts do not collide", () => {
27 const baseInput = {28 const baseInput = {
29 environmentId,
28 threadId,30 threadId,
29 fromTurnCount: 1,31 fromTurnCount: 1,
30 toTurnCount: 2,32 toTurnCount: 2,
@@ -51,6 +53,7 @@ describe("checkpointDiffQueryOptions", () => { · 20 unmodified lines
51 mockNativeApi({ getTurnDiff, getFullThreadDiff });53 mockNativeApi({ getTurnDiff, getFullThreadDiff });
52 54
53 const options = checkpointDiffQueryOptions({55 const options = checkpointDiffQueryOptions({
56 environmentId,
54 threadId,57 threadId,
55 fromTurnCount: 3,58 fromTurnCount: 3,
56 toTurnCount: 4,59 toTurnCount: 4,
@@ -74,6 +77,7 @@ describe("checkpointDiffQueryOptions", () => { · 17 unmodified lines
74 mockNativeApi({ getTurnDiff, getFullThreadDiff });77 mockNativeApi({ getTurnDiff, getFullThreadDiff });
75 78
76 const options = checkpointDiffQueryOptions({79 const options = checkpointDiffQueryOptions({
80 environmentId,
77 threadId,81 threadId,
78 fromTurnCount: 0,82 fromTurnCount: 0,
79 toTurnCount: 2,83 toTurnCount: 2,
@@ -96,6 +100,7 @@ describe("checkpointDiffQueryOptions", () => { · 16 unmodified lines
96 mockNativeApi({ getTurnDiff, getFullThreadDiff });100 mockNativeApi({ getTurnDiff, getFullThreadDiff });
97 101
98 const options = checkpointDiffQueryOptions({102 const options = checkpointDiffQueryOptions({
103 environmentId,
99 threadId,104 threadId,
100 fromTurnCount: 4,105 fromTurnCount: 4,
101 toTurnCount: 3,106 toTurnCount: 3,
@@ -113,6 +118,7 @@ describe("checkpointDiffQueryOptions", () => { · 11 unmodified lines
113 118
114 it("retries checkpoint-not-ready errors longer than generic failures", () => {119 it("retries checkpoint-not-ready errors longer than generic failures", () => {
115 const options = checkpointDiffQueryOptions({120 const options = checkpointDiffQueryOptions({
121 environmentId,
116 threadId,122 threadId,
117 fromTurnCount: 1,123 fromTurnCount: 1,
118 toTurnCount: 2,124 toTurnCount: 2,
@@ -137,6 +143,7 @@ describe("checkpointDiffQueryOptions", () => { · 18 unmodified lines
137 143
138 it("backs off longer for checkpoint-not-ready errors", () => {144 it("backs off longer for checkpoint-not-ready errors", () => {
139 const options = checkpointDiffQueryOptions({145 const options = checkpointDiffQueryOptions({
146 environmentId,
140 threadId,147 threadId,
141 fromTurnCount: 1,148 fromTurnCount: 1,
142 toTurnCount: 2,149 toTurnCount: 2,
apps/web/src/lib/providerReactQuery.ts+11−4
@@ -1,13 +1,15 @@
1import {1import {
2 type EnvironmentId,
2 OrchestrationGetFullThreadDiffInput,3 OrchestrationGetFullThreadDiffInput,
3 OrchestrationGetTurnDiffInput,4 OrchestrationGetTurnDiffInput,
4 ThreadId,5 ThreadId,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import { queryOptions } from "@tanstack/react-query";7import { queryOptions } from "@tanstack/react-query";
7import { Option, Schema } from "effect";8import { Option, Schema } from "effect";
8import { ensureNativeApi } from "../nativeApi";9import { ensureEnvironmentApi } from "../environmentApi";
9 10
10interface CheckpointDiffQueryInput {11interface CheckpointDiffQueryInput {
12 environmentId: EnvironmentId | null;
11 threadId: ThreadId | null;13 threadId: ThreadId | null;
12 fromTurnCount: number | null;14 fromTurnCount: number | null;
13 toTurnCount: number | null;15 toTurnCount: number | null;
@@ -21,6 +23,7 @@ export const providerQueryKeys = { · 7 unmodified lines
21 [23 [
22 "providers",24 "providers",
23 "checkpointDiff",25 "checkpointDiff",
26 input.environmentId ?? null,
24 input.threadId,27 input.threadId,
25 input.fromTurnCount,28 input.fromTurnCount,
26 input.toTurnCount,29 input.toTurnCount,
@@ -95,10 +98,10 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { · 68 unmodified lines
95 return queryOptions({98 return queryOptions({
96 queryKey: providerQueryKeys.checkpointDiff(input),99 queryKey: providerQueryKeys.checkpointDiff(input),
97 queryFn: async () => {100 queryFn: async () => {
98 const api = ensureNativeApi();101 if (!input.environmentId || !input.threadId || decodedRequest._tag === "None") {
99 if (!input.threadId || decodedRequest._tag === "None") {
100 throw new Error("Checkpoint diff is unavailable.");102 throw new Error("Checkpoint diff is unavailable.");
101 }103 }
104 const api = ensureEnvironmentApi(input.environmentId);
102 try {105 try {
103 if (decodedRequest.value.kind === "fullThreadDiff") {106 if (decodedRequest.value.kind === "fullThreadDiff") {
104 return await api.orchestration.getFullThreadDiff(decodedRequest.value.input);107 return await api.orchestration.getFullThreadDiff(decodedRequest.value.input);
@@ -108,7 +111,11 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { · 3 unmodified lines
108 throw new Error(normalizeCheckpointErrorMessage(error), { cause: error });111 throw new Error(normalizeCheckpointErrorMessage(error), { cause: error });
109 }112 }
110 },113 },
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",
112 staleTime: Infinity,119 staleTime: Infinity,
113 retry: (failureCount, error) => {120 retry: (failureCount, error) => {
114 if (isCheckpointTemporarilyUnavailable(error)) {121 if (isCheckpointTemporarilyUnavailable(error)) {
apps/web/src/lib/terminalStateCleanup.test.ts+20−13
@@ -1,58 +1,65 @@
1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
1import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";3import { describe, expect, it } from "vitest";
3 4
4import { collectActiveTerminalThreadIds } from "./terminalStateCleanup";5import { collectActiveTerminalThreadIds } from "./terminalStateCleanup";
5 6
6const threadId = (id: string): ThreadId => ThreadId.makeUnsafe(id);7const threadId = (id: string): ThreadId => ThreadId.makeUnsafe(id);
8const threadKey = (environmentId: string, id: string): string =>
9 scopedThreadKey(scopeThreadRef(environmentId as never, threadId(id)));
7 10
8describe("collectActiveTerminalThreadIds", () => {11describe("collectActiveTerminalThreadIds", () => {
9 it("retains non-deleted server threads", () => {12 it("retains non-deleted server threads", () => {
10 const activeThreadIds = collectActiveTerminalThreadIds({13 const activeThreadIds = collectActiveTerminalThreadIds({
11 snapshotThreads: [14 snapshotThreads: [
12 { id: threadId("server-1"), deletedAt: null, archivedAt: null },15 { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null },
13 { id: threadId("server-2"), deletedAt: null, archivedAt: null },16 { key: threadKey("env-b", "server-2"), deletedAt: null, archivedAt: null },
14 ],17 ],
15 draftThreadIds: [],18 draftThreadKeys: [],
16 });19 });
17 20
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 );
19 });24 });
20 25
21 it("ignores deleted and archived server threads and keeps local draft threads", () => {26 it("ignores deleted and archived server threads and keeps local draft threads", () => {
22 const activeThreadIds = collectActiveTerminalThreadIds({27 const activeThreadIds = collectActiveTerminalThreadIds({
23 snapshotThreads: [28 snapshotThreads: [
24 { id: threadId("server-active"), deletedAt: null, archivedAt: null },29 { key: threadKey("env-a", "server-active"), deletedAt: null, archivedAt: null },
25 {30 {
26 id: threadId("server-deleted"),31 key: threadKey("env-a", "server-deleted"),
27 deletedAt: "2026-03-05T08:00:00.000Z",32 deletedAt: "2026-03-05T08:00:00.000Z",
28 archivedAt: null,33 archivedAt: null,
29 },34 },
30 {35 {
31 id: threadId("server-archived"),36 key: threadKey("env-a", "server-archived"),
32 deletedAt: null,37 deletedAt: null,
33 archivedAt: "2026-03-05T09:00:00.000Z",38 archivedAt: "2026-03-05T09:00:00.000Z",
34 },39 },
35 ],40 ],
36 draftThreadIds: [threadId("local-draft")],41 draftThreadKeys: [threadKey("env-a", "local-draft")],
37 });42 });
38 43
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 );
40 });47 });
41 48
42 it("does not keep draft-linked terminal state for archived server threads", () => {49 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");
44 51
45 const activeThreadIds = collectActiveTerminalThreadIds({52 const activeThreadIds = collectActiveTerminalThreadIds({
46 snapshotThreads: [53 snapshotThreads: [
47 {54 {
48 id: archivedThreadId,55 key: archivedThreadId,
49 deletedAt: null,56 deletedAt: null,
50 archivedAt: "2026-03-05T09:00:00.000Z",57 archivedAt: "2026-03-05T09:00:00.000Z",
51 },58 },
52 ],59 ],
53 draftThreadIds: [archivedThreadId, threadId("local-draft")],60 draftThreadKeys: [archivedThreadId, threadKey("env-a", "local-draft")],
54 });61 });
55 62
56 expect(activeThreadIds).toEqual(new Set([threadId("local-draft")]));63 expect(activeThreadIds).toEqual(new Set([threadKey("env-a", "local-draft")]));
57 });64 });
58});65});
apps/web/src/lib/terminalStateCleanup.ts+9−11
@@ -1,35 +1,33 @@
1import type { ThreadId } from "@t3tools/contracts";
2
3interface TerminalRetentionThread {1interface TerminalRetentionThread {
4 id: ThreadId;2 key: string;
5 deletedAt: string | null;3 deletedAt: string | null;
6 archivedAt: string | null;4 archivedAt: string | null;
7}5}
8 6
9interface CollectActiveTerminalThreadIdsInput {7interface CollectActiveTerminalThreadIdsInput {
10 snapshotThreads: readonly TerminalRetentionThread[];8 snapshotThreads: readonly TerminalRetentionThread[];
11 draftThreadIds: Iterable<ThreadId>;9 draftThreadKeys: Iterable<string>;
12}10}
13 11
14export function collectActiveTerminalThreadIds(12export function collectActiveTerminalThreadIds(
15 input: CollectActiveTerminalThreadIdsInput,13 input: CollectActiveTerminalThreadIdsInput,
16): Set<ThreadId> {14): Set<string> {
17 const activeThreadIds = new Set<ThreadId>();15 const activeThreadIds = new Set<string>();
18 const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.id, thread]));16 const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread]));
19 for (const thread of input.snapshotThreads) {17 for (const thread of input.snapshotThreads) {
20 if (thread.deletedAt !== null) continue;18 if (thread.deletedAt !== null) continue;
21 if (thread.archivedAt !== null) continue;19 if (thread.archivedAt !== null) continue;
22 activeThreadIds.add(thread.id);20 activeThreadIds.add(thread.key);
23 }21 }
24 for (const draftThreadId of input.draftThreadIds) {22 for (const draftThreadKey of input.draftThreadKeys) {
25 const snapshotThread = snapshotThreadById.get(draftThreadId);23 const snapshotThread = snapshotThreadById.get(draftThreadKey);
26 if (24 if (
27 snapshotThread &&25 snapshotThread &&
28 (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null)26 (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null)
29 ) {27 ) {
30 continue;28 continue;
31 }29 }
32 activeThreadIds.add(draftThreadId);30 activeThreadIds.add(draftThreadKey);
33 }31 }
34 return activeThreadIds;32 return activeThreadIds;
35}33}
apps/web/src/lib/utils.test.ts+41−1
@@ -1,6 +1,15 @@
1import { assert, describe, it } from "vitest";1import { assert, describe, expect, it, vi } from "vitest";
2
3const { resolvePrimaryEnvironmentBootstrapUrlMock } = vi.hoisted(() => ({
4 resolvePrimaryEnvironmentBootstrapUrlMock: vi.fn(() => "http://bootstrap.test:4321"),
5}));
6
7vi.mock("../environmentBootstrap", () => ({
8 resolvePrimaryEnvironmentBootstrapUrl: resolvePrimaryEnvironmentBootstrapUrlMock,
9}));
2 10
3import { isWindowsPlatform } from "./utils";11import { isWindowsPlatform } from "./utils";
12import { resolveServerUrl } from "./utils";
4 13
5describe("isWindowsPlatform", () => {14describe("isWindowsPlatform", () => {
6 it("matches Windows platform identifiers", () => {15 it("matches Windows platform identifiers", () => {
@@ -13,3 +22,34 @@ describe("isWindowsPlatform", () => { · 6 unmodified lines
13 assert.isFalse(isWindowsPlatform("darwin"));22 assert.isFalse(isWindowsPlatform("darwin"));
14 });23 });
15});24});
25
26describe("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
4import { twMerge } from "tailwind-merge";4import { twMerge } from "tailwind-merge";
5import * as Random from "effect/Random";5import * as Random from "effect/Random";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap";
8import { DraftId } from "../composerDraftStore";
7 9
8export function cn(...inputs: CxOptions) {10export function cn(...inputs: CxOptions) {
9 return twMerge(cx(inputs));11 return twMerge(cx(inputs));
@@ -34,17 +36,11 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); · 24 unmodified lines
34 36
35export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());37export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());
36 38
39export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID());
40
37export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());41export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());
38 42
39const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty);43const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty);
40const 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};
48 44
49export const resolveServerUrl = (options?: {45export const resolveServerUrl = (options?: {
50 url?: string | undefined;46 url?: string | undefined;
@@ -52,12 +48,9 @@ export const resolveServerUrl = (options?: { · 1 unmodified line
52 pathname?: string | undefined;48 pathname?: string | undefined;
53 searchParams?: Record<string, string> | undefined;49 searchParams?: Record<string, string> | undefined;
54}): string => {50}): string => {
55 const rawUrl = firstNonEmptyString(51 const rawUrl = isNonEmptyString(options?.url)
56 options?.url,52 ? options.url
57 window.desktopBridge?.getWsUrl(),53 : resolvePrimaryEnvironmentBootstrapUrl();
58 import.meta.env.VITE_WS_URL,
59 window.location.origin,
60 );
61 54
62 const parsedUrl = new URL(rawUrl);55 const parsedUrl = new URL(rawUrl);
63 if (options?.protocol) {56 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
2 CommandId,2 CommandId,
3 DEFAULT_SERVER_SETTINGS,3 DEFAULT_SERVER_SETTINGS,
4 type DesktopBridge,4 type DesktopBridge,
5 EnvironmentId,
5 EventId,6 EventId,
6 type GitStatusResult,7 type GitStatusResult,
7 ProjectId,8 ProjectId,
@@ -94,6 +95,17 @@ const rpcClientMock = { · 86 unmodified lines
94vi.mock("./wsRpcClient", () => {95vi.mock("./wsRpcClient", () => {
95 return {96 return {
96 getWsRpcClient: () => rpcClientMock,97 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 }),
97 __resetWsRpcClientForTests: vi.fn(),109 __resetWsRpcClientForTests: vi.fn(),
98 };110 };
99});111});
@@ -121,6 +133,7 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn · 21 unmodified lines
121function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {133function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {
122 return {134 return {
123 getWsUrl: () => null,135 getWsUrl: () => null,
136 getLocalEnvironmentBootstrap: () => null,
124 pickFolder: async () => null,137 pickFolder: async () => null,
125 confirm: async () => true,138 confirm: async () => true,
126 setTheme: async () => undefined,139 setTheme: async () => undefined,
@@ -157,7 +170,21 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [ · 30 unmodified lines
157 },170 },
158];171];
159 172
173const 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
160const baseServerConfig: ServerConfig = {186const baseServerConfig: ServerConfig = {
187 environment: baseEnvironment,
161 cwd: "/tmp/workspace",188 cwd: "/tmp/workspace",
162 keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",189 keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
163 keybindings: [],190 keybindings: [],
@@ -200,12 +227,12 @@ afterEach(() => { · 36 unmodified lines
200 vi.restoreAllMocks();227 vi.restoreAllMocks();
201});228});
202 229
203describe("wsNativeApi", () => {230describe("wsApi", () => {
204 it("forwards server config fetches directly to the RPC client", async () => {231 it("forwards server config fetches directly to the RPC client", async () => {
205 rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig);232 rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig);
206 const { createWsNativeApi } = await import("./wsNativeApi");233 const { createLocalApi } = await import("./localApi");
207 234
208 const api = createWsNativeApi();235 const api = createLocalApi();
209 236
210 await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig);237 await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig);
211 expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith();238 expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith();
@@ -214,9 +241,9 @@ describe("wsNativeApi", () => { · 2 unmodified lines
214 });241 });
215 242
216 it("forwards terminal and orchestration stream events", async () => {243 it("forwards terminal and orchestration stream events", async () => {
217 const { createWsNativeApi } = await import("./wsNativeApi");244 const { createEnvironmentApi } = await import("./environmentApi");
218 245
219 const api = createWsNativeApi();246 const api = createEnvironmentApi(rpcClientMock as never);
220 const onTerminalEvent = vi.fn();247 const onTerminalEvent = vi.fn();
221 const onDomainEvent = vi.fn();248 const onDomainEvent = vi.fn();
222 249
@@ -263,9 +290,9 @@ describe("wsNativeApi", () => { · 40 unmodified lines
263 });290 });
264 291
265 it("forwards git status stream events", async () => {292 it("forwards git status stream events", async () => {
266 const { createWsNativeApi } = await import("./wsNativeApi");293 const { createEnvironmentApi } = await import("./environmentApi");
267 294
268 const api = createWsNativeApi();295 const api = createEnvironmentApi(rpcClientMock as never);
269 const onStatus = vi.fn();296 const onStatus = vi.fn();
270 297
271 api.git.onStatus({ cwd: "/repo" }, onStatus);298 api.git.onStatus({ cwd: "/repo" }, onStatus);
@@ -279,9 +306,9 @@ describe("wsNativeApi", () => { · 7 unmodified lines
279 306
280 it("forwards git status refreshes directly to the RPC client", async () => {307 it("forwards git status refreshes directly to the RPC client", async () => {
281 rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus);308 rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus);
282 const { createWsNativeApi } = await import("./wsNativeApi");309 const { createEnvironmentApi } = await import("./environmentApi");
283 310
284 const api = createWsNativeApi();311 const api = createEnvironmentApi(rpcClientMock as never);
285 312
286 await api.git.refreshStatus({ cwd: "/repo" });313 await api.git.refreshStatus({ cwd: "/repo" });
287 314
@@ -289,9 +316,9 @@ describe("wsNativeApi", () => { · 1 unmodified line
289 });316 });
290 317
291 it("forwards orchestration stream subscription options to the RPC client", async () => {318 it("forwards orchestration stream subscription options to the RPC client", async () => {
292 const { createWsNativeApi } = await import("./wsNativeApi");319 const { createEnvironmentApi } = await import("./environmentApi");
293 320
294 const api = createWsNativeApi();321 const api = createEnvironmentApi(rpcClientMock as never);
295 const onDomainEvent = vi.fn();322 const onDomainEvent = vi.fn();
296 const onResubscribe = vi.fn();323 const onResubscribe = vi.fn();
297 324
@@ -304,9 +331,9 @@ describe("wsNativeApi", () => { · 6 unmodified lines
304 331
305 it("sends orchestration dispatch commands as the direct RPC payload", async () => {332 it("sends orchestration dispatch commands as the direct RPC payload", async () => {
306 rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 });333 rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 });
307 const { createWsNativeApi } = await import("./wsNativeApi");334 const { createEnvironmentApi } = await import("./environmentApi");
308 335
309 const api = createWsNativeApi();336 const api = createEnvironmentApi(rpcClientMock as never);
310 const command = {337 const command = {
311 type: "project.create",338 type: "project.create",
312 commandId: CommandId.makeUnsafe("cmd-1"),339 commandId: CommandId.makeUnsafe("cmd-1"),
@@ -326,9 +353,9 @@ describe("wsNativeApi", () => { · 13 unmodified lines
326 353
327 it("forwards workspace file writes to the project RPC", async () => {354 it("forwards workspace file writes to the project RPC", async () => {
328 rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" });355 rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" });
329 const { createWsNativeApi } = await import("./wsNativeApi");356 const { createEnvironmentApi } = await import("./environmentApi");
330 357
331 const api = createWsNativeApi();358 const api = createEnvironmentApi(rpcClientMock as never);
332 await api.projects.writeFile({359 await api.projects.writeFile({
333 cwd: "/tmp/project",360 cwd: "/tmp/project",
334 relativePath: "plan.md",361 relativePath: "plan.md",
@@ -344,9 +371,9 @@ describe("wsNativeApi", () => { · 9 unmodified lines
344 371
345 it("forwards full-thread diff requests to the orchestration RPC", async () => {372 it("forwards full-thread diff requests to the orchestration RPC", async () => {
346 rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" });373 rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" });
347 const { createWsNativeApi } = await import("./wsNativeApi");374 const { createEnvironmentApi } = await import("./environmentApi");
348 375
349 const api = createWsNativeApi();376 const api = createEnvironmentApi(rpcClientMock as never);
350 await api.orchestration.getFullThreadDiff({377 await api.orchestration.getFullThreadDiff({
351 threadId: ThreadId.makeUnsafe("thread-1"),378 threadId: ThreadId.makeUnsafe("thread-1"),
352 toTurnCount: 1,379 toTurnCount: 1,
@@ -366,9 +393,9 @@ describe("wsNativeApi", () => { · 13 unmodified lines
366 },393 },
367 ];394 ];
368 rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders });395 rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders });
369 const { createWsNativeApi } = await import("./wsNativeApi");396 const { createLocalApi } = await import("./localApi");
370 397
371 const api = createWsNativeApi();398 const api = createLocalApi();
372 399
373 await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders });400 await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders });
374 expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();401 expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();
@@ -380,9 +407,9 @@ describe("wsNativeApi", () => { · 5 unmodified lines
380 enableAssistantStreaming: true,407 enableAssistantStreaming: true,
381 };408 };
382 rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings);409 rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings);
383 const { createWsNativeApi } = await import("./wsNativeApi");410 const { createLocalApi } = await import("./localApi");
384 411
385 const api = createWsNativeApi();412 const api = createLocalApi();
386 413
387 await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual(414 await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual(
388 nextSettings,415 nextSettings,
@@ -396,8 +423,8 @@ describe("wsNativeApi", () => { · 7 unmodified lines
396 const showContextMenu = vi.fn().mockResolvedValue("delete");423 const showContextMenu = vi.fn().mockResolvedValue("delete");
397 getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu });424 getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu });
398 425
399 const { createWsNativeApi } = await import("./wsNativeApi");426 const { createLocalApi } = await import("./localApi");
400 const api = createWsNativeApi();427 const api = createLocalApi();
401 const items = [{ id: "delete", label: "Delete" }] as const;428 const items = [{ id: "delete", label: "Delete" }] as const;
402 429
403 await expect(api.contextMenu.show(items)).resolves.toBe("delete");430 await expect(api.contextMenu.show(items)).resolves.toBe("delete");
@@ -406,9 +433,9 @@ describe("wsNativeApi", () => { · 2 unmodified lines
406 433
407 it("falls back to the browser context menu helper when the desktop bridge is missing", async () => {434 it("falls back to the browser context menu helper when the desktop bridge is missing", async () => {
408 showContextMenuFallbackMock.mockResolvedValue("rename");435 showContextMenuFallbackMock.mockResolvedValue("rename");
409 const { createWsNativeApi } = await import("./wsNativeApi");436 const { createLocalApi } = await import("./localApi");
410 437
411 const api = createWsNativeApi();438 const api = createLocalApi();
412 const items = [{ id: "rename", label: "Rename" }] as const;439 const items = [{ id: "rename", label: "Rename" }] as const;
413 440
414 await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename");441 await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename");
apps/web/src/localApi.ts+94−0
@@ -0,0 +1,94 @@
1import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";
2
3import { resetGitStatusStateForTests } from "./lib/gitStatusState";
4
5import { __resetWsRpcAtomClientForTests } from "./rpc/client";
6import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
7import { resetServerStateForTests } from "./rpc/serverState";
8import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";
9import { getPrimaryWsRpcClientEntry, WsRpcClient, __resetWsRpcClientForTests } from "./wsRpcClient";
10import { showContextMenuFallback } from "./contextMenuFallback";
11
12let cachedApi: LocalApi | undefined;
13
14export 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
65export 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
78export function ensureLocalApi(): LocalApi {
79 const api = readLocalApi();
80 if (!api) {
81 throw new Error("Local API not found");
82 }
83 return api;
84}
85
86export 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 @@
1import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";
2import type { ScopedProjectRef } from "@t3tools/contracts";
3import type { Project } from "./types";
4
5export 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
14export 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 @@
1import type { NativeApi } from "@t3tools/contracts";
2
3import { __resetWsNativeApiForTests, createWsNativeApi } from "./wsNativeApi";
4
5let cachedApi: NativeApi | undefined;
6
7export 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
20export function ensureNativeApi(): NativeApi {
21 const api = readNativeApi();
22 if (!api) {
23 throw new Error("Native API not found");
24 }
25 return api;
26}
27
28export 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
68 }),68 }),
69 ]);69 ]);
70 70
71 expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]);71 expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]);
72 expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]);72 expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]);
73 expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]);73 expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]);
74 expect(effects.needsProviderInvalidation).toBe(false);74 expect(effects.needsProviderInvalidation).toBe(false);
@@ -106,7 +106,7 @@ describe("deriveOrchestrationBatchEffects", () => { · 31 unmodified lines
106 }),106 }),
107 ]);107 ]);
108 108
109 expect(effects.clearPromotedDraftThreadIds).toEqual([threadId]);109 expect(effects.promoteDraftThreadIds).toEqual([threadId]);
110 expect(effects.clearDeletedThreadIds).toEqual([]);110 expect(effects.clearDeletedThreadIds).toEqual([]);
111 expect(effects.removeTerminalStateThreadIds).toEqual([]);111 expect(effects.removeTerminalStateThreadIds).toEqual([]);
112 expect(effects.needsProviderInvalidation).toBe(true);112 expect(effects.needsProviderInvalidation).toBe(true);
@@ -127,7 +127,7 @@ describe("deriveOrchestrationBatchEffects", () => { · 14 unmodified lines
127 }),127 }),
128 ]);128 ]);
129 129
130 expect(effects.clearPromotedDraftThreadIds).toEqual([]);130 expect(effects.promoteDraftThreadIds).toEqual([]);
131 expect(effects.clearDeletedThreadIds).toEqual([]);131 expect(effects.clearDeletedThreadIds).toEqual([]);
132 expect(effects.removeTerminalStateThreadIds).toEqual([]);132 expect(effects.removeTerminalStateThreadIds).toEqual([]);
133 });133 });
apps/web/src/orchestrationEventEffects.ts+4−4
@@ -1,7 +1,7 @@
1import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts";1import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts";
2 2
3export interface OrchestrationBatchEffects {3export interface OrchestrationBatchEffects {
4 clearPromotedDraftThreadIds: ThreadId[];4 promoteDraftThreadIds: ThreadId[];
5 clearDeletedThreadIds: ThreadId[];5 clearDeletedThreadIds: ThreadId[];
6 removeTerminalStateThreadIds: ThreadId[];6 removeTerminalStateThreadIds: ThreadId[];
7 needsProviderInvalidation: boolean;7 needsProviderInvalidation: boolean;
@@ -70,12 +70,12 @@ export function deriveOrchestrationBatchEffects( · 62 unmodified lines
70 }70 }
71 }71 }
72 72
73 const clearPromotedDraftThreadIds: ThreadId[] = [];73 const promoteDraftThreadIds: ThreadId[] = [];
74 const clearDeletedThreadIds: ThreadId[] = [];74 const clearDeletedThreadIds: ThreadId[] = [];
75 const removeTerminalStateThreadIds: ThreadId[] = [];75 const removeTerminalStateThreadIds: ThreadId[] = [];
76 for (const [threadId, effect] of threadLifecycleEffects) {76 for (const [threadId, effect] of threadLifecycleEffects) {
77 if (effect.clearPromotedDraft) {77 if (effect.clearPromotedDraft) {
78 clearPromotedDraftThreadIds.push(threadId);78 promoteDraftThreadIds.push(threadId);
79 }79 }
80 if (effect.clearDeletedThread) {80 if (effect.clearDeletedThread) {
81 clearDeletedThreadIds.push(threadId);81 clearDeletedThreadIds.push(threadId);
@@ -86,7 +86,7 @@ export function deriveOrchestrationBatchEffects( · 4 unmodified lines
86 }86 }
87 87
88 return {88 return {
89 clearPromotedDraftThreadIds,89 promoteDraftThreadIds,
90 clearDeletedThreadIds,90 clearDeletedThreadIds,
91 removeTerminalStateThreadIds,91 removeTerminalStateThreadIds,
92 needsProviderInvalidation,92 needsProviderInvalidation,
apps/web/src/routeTree.gen.ts+39−17
@@ -14,7 +14,8 @@ import { Route as ChatRouteImport } from './routes/_chat' · 13 unmodified lines
14import { Route as ChatIndexRouteImport } from './routes/_chat.index'14import { Route as ChatIndexRouteImport } from './routes/_chat.index'
15import { Route as SettingsGeneralRouteImport } from './routes/settings.general'15import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
16import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'16import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
17import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId'17import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
18import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
18 19
19const SettingsRoute = SettingsRouteImport.update({20const SettingsRoute = SettingsRouteImport.update({
20 id: '/settings',21 id: '/settings',
@@ -40,58 +41,70 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ · 19 unmodified lines
40 path: '/archived',41 path: '/archived',
41 getParentRoute: () => SettingsRoute,42 getParentRoute: () => SettingsRoute,
42} as any)43} as any)
43const ChatThreadIdRoute = ChatThreadIdRouteImport.update({44const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({
44 id: '/$threadId',45 id: '/draft/$draftId',
45 path: '/$threadId',46 path: '/draft/$draftId',
46 getParentRoute: () => ChatRoute,47 getParentRoute: () => ChatRoute,
47} as any)48} as any)
49const ChatEnvironmentIdThreadIdRoute =
50 ChatEnvironmentIdThreadIdRouteImport.update({
51 id: '/$environmentId/$threadId',
52 path: '/$environmentId/$threadId',
53 getParentRoute: () => ChatRoute,
54 } as any)
48 55
49export interface FileRoutesByFullPath {56export interface FileRoutesByFullPath {
50 '/': typeof ChatIndexRoute57 '/': typeof ChatIndexRoute
51 '/settings': typeof SettingsRouteWithChildren58 '/settings': typeof SettingsRouteWithChildren
52 '/$threadId': typeof ChatThreadIdRoute
53 '/settings/archived': typeof SettingsArchivedRoute59 '/settings/archived': typeof SettingsArchivedRoute
54 '/settings/general': typeof SettingsGeneralRoute60 '/settings/general': typeof SettingsGeneralRoute
61 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
62 '/draft/$draftId': typeof ChatDraftDraftIdRoute
55}63}
56export interface FileRoutesByTo {64export interface FileRoutesByTo {
57 '/settings': typeof SettingsRouteWithChildren65 '/settings': typeof SettingsRouteWithChildren
58 '/$threadId': typeof ChatThreadIdRoute
59 '/settings/archived': typeof SettingsArchivedRoute66 '/settings/archived': typeof SettingsArchivedRoute
60 '/settings/general': typeof SettingsGeneralRoute67 '/settings/general': typeof SettingsGeneralRoute
61 '/': typeof ChatIndexRoute68 '/': typeof ChatIndexRoute
69 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
70 '/draft/$draftId': typeof ChatDraftDraftIdRoute
62}71}
63export interface FileRoutesById {72export interface FileRoutesById {
64 __root__: typeof rootRouteImport73 __root__: typeof rootRouteImport
65 '/_chat': typeof ChatRouteWithChildren74 '/_chat': typeof ChatRouteWithChildren
66 '/settings': typeof SettingsRouteWithChildren75 '/settings': typeof SettingsRouteWithChildren
67 '/_chat/$threadId': typeof ChatThreadIdRoute
68 '/settings/archived': typeof SettingsArchivedRoute76 '/settings/archived': typeof SettingsArchivedRoute
69 '/settings/general': typeof SettingsGeneralRoute77 '/settings/general': typeof SettingsGeneralRoute
70 '/_chat/': typeof ChatIndexRoute78 '/_chat/': typeof ChatIndexRoute
79 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
80 '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute
71}81}
72export interface FileRouteTypes {82export interface FileRouteTypes {
73 fileRoutesByFullPath: FileRoutesByFullPath83 fileRoutesByFullPath: FileRoutesByFullPath
74 fullPaths:84 fullPaths:
75 | '/'85 | '/'
76 | '/settings'86 | '/settings'
77 | '/$threadId'
78 | '/settings/archived'87 | '/settings/archived'
79 | '/settings/general'88 | '/settings/general'
89 | '/$environmentId/$threadId'
90 | '/draft/$draftId'
80 fileRoutesByTo: FileRoutesByTo91 fileRoutesByTo: FileRoutesByTo
81 to:92 to:
82 | '/settings'93 | '/settings'
83 | '/$threadId'
84 | '/settings/archived'94 | '/settings/archived'
85 | '/settings/general'95 | '/settings/general'
86 | '/'96 | '/'
97 | '/$environmentId/$threadId'
98 | '/draft/$draftId'
87 id:99 id:
88 | '__root__'100 | '__root__'
89 | '/_chat'101 | '/_chat'
90 | '/settings'102 | '/settings'
91 | '/_chat/$threadId'
92 | '/settings/archived'103 | '/settings/archived'
93 | '/settings/general'104 | '/settings/general'
94 | '/_chat/'105 | '/_chat/'
106 | '/_chat/$environmentId/$threadId'
107 | '/_chat/draft/$draftId'
95 fileRoutesById: FileRoutesById108 fileRoutesById: FileRoutesById
96}109}
97export interface RootRouteChildren {110export interface RootRouteChildren {
@@ -136,24 +149,33 @@ declare module '@tanstack/react-router' { · 38 unmodified lines
136 preLoaderRoute: typeof SettingsArchivedRouteImport149 preLoaderRoute: typeof SettingsArchivedRouteImport
137 parentRoute: typeof SettingsRoute150 parentRoute: typeof SettingsRoute
138 }151 }
139 '/_chat/$threadId': {152 '/_chat/draft/$draftId': {
140 id: '/_chat/$threadId'153 id: '/_chat/draft/$draftId'
141 path: '/$threadId'154 path: '/draft/$draftId'
142 fullPath: '/$threadId'155 fullPath: '/draft/$draftId'
143 preLoaderRoute: typeof ChatThreadIdRouteImport156 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
144 parentRoute: typeof ChatRoute164 parentRoute: typeof ChatRoute
145 }165 }
146 }166 }
147}167}
148 168
149interface ChatRouteChildren {169interface ChatRouteChildren {
150 ChatThreadIdRoute: typeof ChatThreadIdRoute
151 ChatIndexRoute: typeof ChatIndexRoute170 ChatIndexRoute: typeof ChatIndexRoute
171 ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute
172 ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute
152}173}
153 174
154const ChatRouteChildren: ChatRouteChildren = {175const ChatRouteChildren: ChatRouteChildren = {
155 ChatThreadIdRoute: ChatThreadIdRoute,
156 ChatIndexRoute: ChatIndexRoute,176 ChatIndexRoute: ChatIndexRoute,
177 ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute,
178 ChatDraftDraftIdRoute: ChatDraftDraftIdRoute,
157}179}
158 180
159const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)181const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
apps/web/src/routes/__root.tsx+319−166
@@ -1,8 +1,15 @@
1import {1import {
2 type EnvironmentId,
2 OrchestrationEvent,3 OrchestrationEvent,
3 type ServerLifecycleWelcomePayload,4 type ServerLifecycleWelcomePayload,
4 ThreadId,5 ThreadId,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import {
8 scopedProjectKey,
9 scopedThreadKey,
10 scopeProjectRef,
11 scopeThreadRef,
12} from "@t3tools/client-runtime";
6import {13import {
7 Outlet,14 Outlet,
8 createRootRouteWithContext,15 createRootRouteWithContext,
@@ -10,7 +17,7 @@ import { · 1 unmodified line
10 useNavigate,17 useNavigate,
11 useLocation,18 useLocation,
12} from "@tanstack/react-router";19} from "@tanstack/react-router";
13import { useEffect, useEffectEvent, useRef } from "react";20import { useEffect, useEffectEvent, useRef, useState } from "react";
14import { QueryClient, useQueryClient } from "@tanstack/react-query";21import { QueryClient, useQueryClient } from "@tanstack/react-query";
15import { Throttler } from "@tanstack/react-pacer";22import { Throttler } from "@tanstack/react-pacer";
16 23
@@ -24,7 +31,7 @@ import { · 7 unmodified lines
24import { Button } from "../components/ui/button";31import { Button } from "../components/ui/button";
25import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";32import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
26import { resolveAndPersistPreferredEditor } from "../editorPreferences";33import { resolveAndPersistPreferredEditor } from "../editorPreferences";
27import { readNativeApi } from "../nativeApi";34import { readLocalApi } from "../localApi";
28import {35import {
29 getServerConfigUpdatedNotification,36 getServerConfigUpdatedNotification,
30 ServerConfigUpdatedNotification,37 ServerConfigUpdatedNotification,
@@ -34,11 +41,15 @@ import { · 3 unmodified lines
34 useServerWelcomeSubscription,41 useServerWelcomeSubscription,
35} from "../rpc/serverState";42} from "../rpc/serverState";
36import {43import {
37 clearPromotedDraftThread,44 markPromotedDraftThreadByRef,
38 clearPromotedDraftThreads,45 markPromotedDraftThreadsByRef,
39 useComposerDraftStore,46 useComposerDraftStore,
40} from "../composerDraftStore";47} from "../composerDraftStore";
41import { selectProjects, selectThreadById, selectThreads, useStore } from "../store";48import {
49 selectProjectsAcrossEnvironments,
50 selectThreadsAcrossEnvironments,
51 useStore,
52} from "../store";
42import { useUiStateStore } from "../uiStateStore";53import { useUiStateStore } from "../uiStateStore";
43import { useTerminalStateStore } from "../terminalStateStore";54import { useTerminalStateStore } from "../terminalStateStore";
44import { migrateLocalSettingsToServer } from "../hooks/useSettings";55import { migrateLocalSettingsToServer } from "../hooks/useSettings";
@@ -48,7 +59,15 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; · 3 unmodified lines
48import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";59import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";
49import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";60import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";
50import { deriveReplayRetryDecision } from "../orchestrationRecovery";61import { deriveReplayRetryDecision } from "../orchestrationRecovery";
51import { getWsRpcClient } from "~/wsRpcClient";62import { selectThreadByRef } from "../store";
63import {
64 bindPrimaryWsRpcClientEnvironment,
65 bindWsRpcClientEntryEnvironment,
66 getPrimaryWsRpcClientEntry,
67 listWsRpcClientEntries,
68 subscribeWsRpcClientRegistry,
69 type WsRpcClientEntry,
70} from "~/wsRpcClient";
52 71
53export const Route = createRootRouteWithContext<{72export const Route = createRootRouteWithContext<{
54 queryClient: QueryClient;73 queryClient: QueryClient;
@@ -61,7 +80,7 @@ export const Route = createRootRouteWithContext<{ · 6 unmodified lines
61});80});
62 81
63function RootRouteView() {82function RootRouteView() {
64 if (!readNativeApi()) {83 if (!readLocalApi()) {
65 return (84 return (
66 <div className="flex h-screen flex-col bg-background text-foreground">85 <div className="flex h-screen flex-col bg-background text-foreground">
67 <div className="flex flex-1 items-center justify-center">86 <div className="flex flex-1 items-center justify-center">
@@ -201,14 +220,24 @@ function coalesceOrchestrationUiEvents( · 133 unmodified lines
201const REPLAY_RECOVERY_RETRY_DELAY_MS = 100;220const REPLAY_RECOVERY_RETRY_DELAY_MS = 100;
202const MAX_NO_PROGRESS_REPLAY_RETRIES = 3;221const MAX_NO_PROGRESS_REPLAY_RETRIES = 3;
203 222
223function 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
204function ServerStateBootstrap() {232function ServerStateBootstrap() {
205 useEffect(() => startServerStateSync(getWsRpcClient().server), []);233 useEffect(() => startServerStateSync(getPrimaryWsRpcClientEntry().client.server), []);
206 234
207 return null;235 return null;
208}236}
209 237
210function EventRouter() {238function EventRouter() {
211 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);239 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);
240 const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId);
212 const syncServerReadModel = useStore((store) => store.syncServerReadModel);241 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
213 const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded);242 const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded);
214 const syncProjects = useUiStateStore((store) => store.syncProjects);243 const syncProjects = useUiStateStore((store) => store.syncProjects);
@@ -226,15 +255,22 @@ function EventRouter() { · 11 unmodified lines
226 const handledBootstrapThreadIdRef = useRef<string | null>(null);255 const handledBootstrapThreadIdRef = useRef<string | null>(null);
227 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);256 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);
228 const disposedRef = useRef(false);257 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);
230 const serverConfig = useServerConfig();262 const serverConfig = useServerConfig();
263 const clientEntries = useRegisteredWsRpcClientEntries();
231 264
232 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {265 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {
233 if (!payload) return;266 if (!payload) return;
234 267
268 bindPrimaryWsRpcClientEnvironment(payload.environment.environmentId);
269 setActiveEnvironmentId(payload.environment.environmentId);
270 schedulePendingDomainEventFlushRef.current();
235 migrateLocalSettingsToServer();271 migrateLocalSettingsToServer();
236 void (async () => {272 void (async () => {
237 await bootstrapFromSnapshotRef.current();273 await bootstrapFromSnapshotRef.current(payload.environment.environmentId);
238 if (disposedRef.current) {274 if (disposedRef.current) {
239 return;275 return;
240 }276 }
@@ -242,7 +278,12 @@ function EventRouter() { · 1 unmodified line
242 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {278 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
243 return;279 return;
244 }280 }
245 setProjectExpanded(payload.bootstrapProjectId, true);281 setProjectExpanded(
282 scopedProjectKey(
283 scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),
284 ),
285 true,
286 );
246 287
247 if (readPathname() !== "/") {288 if (readPathname() !== "/") {
248 return;289 return;
@@ -251,8 +292,11 @@ function EventRouter() { · 2 unmodified lines
251 return;292 return;
252 }293 }
253 await navigate({294 await navigate({
254 to: "/$threadId",295 to: "/$environmentId/$threadId",
255 params: { threadId: payload.bootstrapThreadId },296 params: {
297 environmentId: payload.environment.environmentId,
298 threadId: payload.bootstrapThreadId,
299 },
256 replace: true,300 replace: true,
257 });301 });
258 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;302 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
@@ -289,7 +333,7 @@ function EventRouter() { · 30 unmodified lines
289 actionProps: {333 actionProps: {
290 children: "Open keybindings.json",334 children: "Open keybindings.json",
291 onClick: () => {335 onClick: () => {
292 const api = readNativeApi();336 const api = readLocalApi();
293 if (!api) {337 if (!api) {
294 return;338 return;
295 }339 }
@@ -317,40 +361,49 @@ function EventRouter() { · 21 unmodified lines
317 );361 );
318 362
319 useEffect(() => {363 useEffect(() => {
320 const api = readNativeApi();364 if (!serverConfig) {
321 if (!api) return;365 return;
366 }
367
368 bindPrimaryWsRpcClientEnvironment(serverConfig.environment.environmentId);
369 setActiveEnvironmentId(serverConfig.environment.environmentId);
370 schedulePendingDomainEventFlushRef.current();
371 }, [serverConfig, setActiveEnvironmentId]);
372
373 useEffect(() => {
322 let disposed = false;374 let disposed = false;
323 disposedRef.current = false;375 disposedRef.current = false;
324 const recovery = createOrchestrationRecoveryCoordinator();
325 let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null;
326 let needsProviderInvalidation = false;376 let needsProviderInvalidation = false;
327 const pendingDomainEvents: OrchestrationEvent[] = [];377 const primaryClientKey = getPrimaryWsRpcClientEntry().key;
328 let flushPendingDomainEventsScheduled = false;
329 378
330 const reconcileSnapshotDerivedState = () => {379 const reconcileSnapshotDerivedState = () => {
331 const storeState = useStore.getState();380 const storeState = useStore.getState();
332 const threads = selectThreads(storeState);381 const threads = selectThreadsAcrossEnvironments(storeState);
333 const projects = selectProjects(storeState);382 const projects = selectProjectsAcrossEnvironments(storeState);
334 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));383 syncProjects(
384 projects.map((project) => ({
385 key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
386 cwd: project.cwd,
387 })),
388 );
335 syncThreads(389 syncThreads(
336 threads.map((thread) => ({390 threads.map((thread) => ({
337 id: thread.id,391 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
338 seedVisitedAt: thread.updatedAt ?? thread.createdAt,392 seedVisitedAt: thread.updatedAt ?? thread.createdAt,
339 })),393 })),
340 );394 );
341 clearPromotedDraftThreads(threads.map((thread) => thread.id));395 markPromotedDraftThreadsByRef(
342 const draftThreadIds = Object.keys(396 threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)),
343 useComposerDraftStore.getState().draftThreadsByThreadId,397 );
344 ) as ThreadId[];398 const activeThreadKeys = collectActiveTerminalThreadIds({
345 const activeThreadIds = collectActiveTerminalThreadIds({
346 snapshotThreads: threads.map((thread) => ({399 snapshotThreads: threads.map((thread) => ({
347 id: thread.id,400 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
348 deletedAt: null,401 deletedAt: null,
349 archivedAt: thread.archivedAt,402 archivedAt: thread.archivedAt,
350 })),403 })),
351 draftThreadIds,404 draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(),
352 });405 });
353 removeOrphanedTerminalStates(activeThreadIds);406 removeOrphanedTerminalStates(activeThreadKeys);
354 };407 };
355 408
356 const queryInvalidationThrottler = new Throttler(409 const queryInvalidationThrottler = new Throttler(
@@ -371,7 +424,11 @@ function EventRouter() { · 14 unmodified lines
371 },424 },
372 );425 );
373 426
374 const applyEventBatch = (events: ReadonlyArray<OrchestrationEvent>) => {427 const applyEventBatch = (
428 events: ReadonlyArray<OrchestrationEvent>,
429 environmentId: EnvironmentId,
430 recovery: ReturnType<typeof createOrchestrationRecoveryCoordinator>,
431 ) => {
375 const nextEvents = recovery.markEventBatchApplied(events);432 const nextEvents = recovery.markEventBatchApplied(events);
376 if (nextEvents.length === 0) {433 if (nextEvents.length === 0) {
377 return;434 return;
@@ -391,189 +448,284 @@ function EventRouter() { · 13 unmodified lines
391 void queryInvalidationThrottler.maybeExecute();448 void queryInvalidationThrottler.maybeExecute();
392 }449 }
393 450
394 applyOrchestrationEvents(uiEvents);451 applyOrchestrationEvents(uiEvents, environmentId);
395 if (needsProjectUiSync) {452 if (needsProjectUiSync) {
396 const projects = selectProjects(useStore.getState());453 const projects = selectProjectsAcrossEnvironments(useStore.getState());
397 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));454 syncProjects(
455 projects.map((project) => ({
456 key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
457 cwd: project.cwd,
458 })),
459 );
398 }460 }
399 const needsThreadUiSync = nextEvents.some(461 const needsThreadUiSync = nextEvents.some(
400 (event) => event.type === "thread.created" || event.type === "thread.deleted",462 (event) => event.type === "thread.created" || event.type === "thread.deleted",
401 );463 );
402 if (needsThreadUiSync) {464 if (needsThreadUiSync) {
403 const threads = selectThreads(useStore.getState());465 const threads = selectThreadsAcrossEnvironments(useStore.getState());
404 syncThreads(466 syncThreads(
405 threads.map((thread) => ({467 threads.map((thread) => ({
406 id: thread.id,468 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
407 seedVisitedAt: thread.updatedAt ?? thread.createdAt,469 seedVisitedAt: thread.updatedAt ?? thread.createdAt,
408 })),470 })),
409 );471 );
410 }472 }
411 const draftStore = useComposerDraftStore.getState();473 const draftStore = useComposerDraftStore.getState();
412 for (const threadId of batchEffects.clearPromotedDraftThreadIds) {474 for (const threadId of batchEffects.promoteDraftThreadIds) {
413 clearPromotedDraftThread(threadId);475 markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId));
414 }476 }
415 for (const threadId of batchEffects.clearDeletedThreadIds) {477 for (const threadId of batchEffects.clearDeletedThreadIds) {
416 draftStore.clearDraftThread(threadId);478 draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId));
417 clearThreadUi(threadId);479 clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId)));
418 }480 }
419 for (const threadId of batchEffects.removeTerminalStateThreadIds) {481 for (const threadId of batchEffects.removeTerminalStateThreadIds) {
420 removeTerminalState(threadId);482 removeTerminalState(scopeThreadRef(environmentId, threadId));
421 }483 }
422 };484 };
423 const flushPendingDomainEvents = () => {485 const clientContexts = clientEntries.map((entry) => {
424 flushPendingDomainEventsScheduled = false;486 const recovery = createOrchestrationRecoveryCoordinator();
425 if (disposed || pendingDomainEvents.length === 0) {487 let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null;
426 return;488 const pendingDomainEvents: OrchestrationEvent[] = [];
427 }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 };
428 500
429 const events = pendingDomainEvents.splice(0, pendingDomainEvents.length);501 const flushPendingDomainEvents = () => {
430 applyEventBatch(events);502 flushPendingDomainEventsScheduled = false;
431 };503 if (disposed || pendingDomainEvents.length === 0 || boundEnvironmentId === null) {
432 const schedulePendingDomainEventFlush = () => {504 return;
433 if (flushPendingDomainEventsScheduled) {505 }
434 return;
435 }
436 506
437 flushPendingDomainEventsScheduled = true;507 const events = pendingDomainEvents.splice(0, pendingDomainEvents.length);
438 queueMicrotask(flushPendingDomainEvents);508 applyEventBatch(events, boundEnvironmentId, recovery);
439 };509 };
440 510
441 const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => {511 const schedulePendingDomainEventFlush = () => {
442 if (!recovery.beginReplayRecovery(reason)) {512 if (flushPendingDomainEventsScheduled) {
443 return;513 return;
444 }514 }
445 515
446 const fromSequenceExclusive = recovery.getState().latestSequence;516 flushPendingDomainEventsScheduled = true;
447 try {517 queueMicrotask(flushPendingDomainEvents);
448 const events = await api.orchestration.replayEvents(fromSequenceExclusive);518 };
449 if (!disposed) {519
450 applyEventBatch(events);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;
451 }543 }
452 } catch {
453 replayRetryTracker = null;
454 recovery.failReplayRecovery();
455 void fallbackToSnapshotRecovery();
456 return;
457 }
458 544
459 if (!disposed) {545 try {
460 const replayCompletion = recovery.completeReplayRecovery();546 const snapshot = await entry.client.orchestration.getSnapshot();
461 const retryDecision = deriveReplayRetryDecision({547 if (!disposed) {
462 previousTracker: replayRetryTracker,548 bindEnvironmentId(environmentId);
463 completion: replayCompletion,549 syncServerReadModel(snapshot, environmentId);
464 recoveryState: recovery.getState(),550 reconcileSnapshotDerivedState();
465 baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS,551 if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {
466 maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES,552 void runReplayRecovery("sequence-gap");
467 });553 }
468 replayRetryTracker = retryDecision.tracker;554 }
555 } catch {
556 recovery.failSnapshotRecovery();
557 }
558 };
469 559
470 if (retryDecision.shouldRetry) {560 const fallbackToSnapshotRecovery = async (): Promise<void> => {
471 if (retryDecision.delayMs > 0) {561 if (boundEnvironmentId === null) {
472 await new Promise<void>((resolve) => {562 return;
473 setTimeout(resolve, retryDecision.delayMs);563 }
474 });564 await runSnapshotRecovery("replay-failed", boundEnvironmentId);
475 if (disposed) {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();
476 return;579 return;
477 }580 }
581 applyEventBatch(events, boundEnvironmentId, recovery);
478 }582 }
479 void runReplayRecovery(reason);583 } catch {
480 } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") {584 replayRetryTracker = null;
481 console.warn(585 recovery.failReplayRecovery();
482 "[orchestration-recovery]",586 void fallbackToSnapshotRecovery();
483 "Stopping replay recovery after no-progress retries.",587 return;
484 {
485 state: recovery.getState(),
486 },
487 );
488 }588 }
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 }
511 589
512 try {
513 const snapshot = await api.orchestration.getSnapshot();
514 if (!disposed) {590 if (!disposed) {
515 syncServerReadModel(snapshot);591 const replayCompletion = recovery.completeReplayRecovery();
516 reconcileSnapshotDerivedState();592 const retryDecision = deriveReplayRetryDecision({
517 if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {593 previousTracker: replayRetryTracker,
518 void runReplayRecovery("sequence-gap");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 );
519 }621 }
520 }622 }
521 } catch {623 };
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;
531 624
532 const fallbackToSnapshotRecovery = async (): Promise<void> => {625 const unsubLifecycle = entry.client.server.subscribeLifecycle((event) => {
533 await runSnapshotRecovery("replay-failed");626 if (event.type === "welcome") {
534 };627 bindEnvironmentId(event.payload.environment.environmentId);
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;
542 }628 }
543 if (action === "recover") {629 });
544 flushPendingDomainEvents();630 const unsubConfig = entry.client.server.subscribeConfig((event) => {
545 void runReplayRecovery("sequence-gap");631 if (event.type === "snapshot") {
632 bindEnvironmentId(event.config.environment.environmentId);
546 }633 }
547 },634 });
548 {635 if (boundEnvironmentId === null) {
549 onResubscribe: () => {636 void entry.client.server
550 if (disposed) {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();
551 return;651 return;
552 }652 }
553 flushPendingDomainEvents();653 if (action === "recover") {
554 void runReplayRecovery("resubscribe");654 flushPendingDomainEvents();
655 void runReplayRecovery("sequence-gap");
656 }
555 },657 },
556 },658 {
557 );659 onResubscribe: () => {
558 const unsubTerminalEvent = api.terminal.onEvent((event) => {660 if (disposed) {
559 const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState());661 return;
560 if (thread && thread.archivedAt !== null) {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) {
561 return;710 return;
562 }711 }
563 applyTerminalEvent(event);712 primaryClientContext.bindEnvironmentId(environmentId);
564 });713 await primaryClientContext.runSnapshotRecovery("bootstrap", environmentId);
714 };
715
565 return () => {716 return () => {
566 disposed = true;717 disposed = true;
567 disposedRef.current = true;718 disposedRef.current = true;
568 needsProviderInvalidation = false;719 needsProviderInvalidation = false;
569 flushPendingDomainEventsScheduled = false;720 schedulePendingDomainEventFlushRef.current = () => undefined;
570 pendingDomainEvents.length = 0;
571 queryInvalidationThrottler.cancel();721 queryInvalidationThrottler.cancel();
572 unsubDomainEvent();722 for (const context of clientContexts) {
573 unsubTerminalEvent();723 context.cleanup();
724 }
574 };725 };
575 }, [726 }, [
576 applyOrchestrationEvents,727 applyOrchestrationEvents,
728 clientEntries,
577 navigate,729 navigate,
578 queryClient,730 queryClient,
579 removeTerminalState,731 removeTerminalState,
@@ -581,6 +733,7 @@ function EventRouter() { · 1 unmodified line
581 applyTerminalEvent,733 applyTerminalEvent,
582 clearThreadUi,734 clearThreadUi,
583 setProjectExpanded,735 setProjectExpanded,
736 setActiveEnvironmentId,
584 syncProjects,737 syncProjects,
585 syncServerReadModel,738 syncServerReadModel,
586 syncThreads,739 syncThreads,
apps/web/src/routes/_chat.$environmentId.$threadId.tsxrenamed from apps/web/src/routes/_chat.$threadId.tsx+61−24
@@ -1,8 +1,8 @@
1import { ThreadId } from "@t3tools/contracts";
2import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";1import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";
3import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react";2import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
4 3
5import ChatView from "../components/ChatView";4import ChatView from "../components/ChatView";
5import { threadHasStarted } from "../components/ChatView.logic";
6import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";6import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
7import {7import {
8 DiffPanelHeaderSkeleton,8 DiffPanelHeaderSkeleton,
@@ -10,14 +10,16 @@ import { · 1 unmodified line
10 DiffPanelShell,10 DiffPanelShell,
11 type DiffPanelMode,11 type DiffPanelMode,
12} from "../components/DiffPanelShell";12} from "../components/DiffPanelShell";
13import { useComposerDraftStore } from "../composerDraftStore";13import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
14import {14import {
15 type DiffRouteSearch,15 type DiffRouteSearch,
16 parseDiffRouteSearch,16 parseDiffRouteSearch,
17 stripDiffSearchParams,17 stripDiffSearchParams,
18} from "../diffRouteSearch";18} from "../diffRouteSearch";
19import { useMediaQuery } from "../hooks/useMediaQuery";19import { useMediaQuery } from "../hooks/useMediaQuery";
20import { useStore } from "../store";20import { selectEnvironmentState, selectThreadByRef, useStore } from "../store";
21import { createThreadSelectorByRef } from "../storeSelectors";
22import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";
21import { Sheet, SheetPopup } from "../components/ui/sheet";23import { Sheet, SheetPopup } from "../components/ui/sheet";
22import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";24import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
23 25
@@ -161,39 +163,60 @@ const DiffPanelInlineSidebar = (props: { · 137 unmodified lines
161};163};
162 164
163function ChatThreadRouteView() {165function ChatThreadRouteView() {
164 const bootstrapComplete = useStore((store) => store.bootstrapComplete);
165 const navigate = useNavigate();166 const navigate = useNavigate();
166 const threadId = Route.useParams({167 const threadRef = Route.useParams({
167 select: (params) => ThreadId.makeUnsafe(params.threadId),168 select: (params) => resolveThreadRouteRef(params),
168 });169 });
169 const search = Route.useSearch();170 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 );
171 const draftThreadExists = useComposerDraftStore((store) =>179 const draftThreadExists = useComposerDraftStore((store) =>
172 Object.hasOwn(store.draftThreadsByThreadId, threadId),180 threadRef ? store.getDraftThreadByRef(threadRef) !== null : false,
173 );181 );
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 });
174 const routeThreadExists = threadExists || draftThreadExists;191 const routeThreadExists = threadExists || draftThreadExists;
192 const serverThreadStarted = threadHasStarted(serverThread);
193 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
175 const diffOpen = search.diff === "1";194 const diffOpen = search.diff === "1";
176 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);195 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.
179 const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen);196 const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen);
180 const closeDiff = useCallback(() => {197 const closeDiff = useCallback(() => {
198 if (!threadRef) {
199 return;
200 }
181 void navigate({201 void navigate({
182 to: "/$threadId",202 to: "/$environmentId/$threadId",
183 params: { threadId },203 params: buildThreadRouteParams(threadRef),
184 search: { diff: undefined },204 search: { diff: undefined },
185 });205 });
186 }, [navigate, threadId]);206 }, [navigate, threadRef]);
187 const openDiff = useCallback(() => {207 const openDiff = useCallback(() => {
208 if (!threadRef) {
209 return;
210 }
188 void navigate({211 void navigate({
189 to: "/$threadId",212 to: "/$environmentId/$threadId",
190 params: { threadId },213 params: buildThreadRouteParams(threadRef),
191 search: (previous) => {214 search: (previous) => {
192 const rest = stripDiffSearchParams(previous);215 const rest = stripDiffSearchParams(previous);
193 return { ...rest, diff: "1" };216 return { ...rest, diff: "1" };
194 },217 },
195 });218 });
196 }, [navigate, threadId]);219 }, [navigate, threadRef]);
197 220
198 useEffect(() => {221 useEffect(() => {
199 if (diffOpen) {222 if (diffOpen) {
@@ -202,17 +225,23 @@ function ChatThreadRouteView() { · 2 unmodified lines
202 }, [diffOpen]);225 }, [diffOpen]);
203 226
204 useEffect(() => {227 useEffect(() => {
205 if (!bootstrapComplete) {228 if (!threadRef || !bootstrapComplete) {
206 return;229 return;
207 }230 }
208 231
209 if (!routeThreadExists) {232 if (!routeThreadExists && environmentHasAnyThreads) {
210 void navigate({ to: "/", replace: true });233 void navigate({ to: "/", replace: true });
234 }
235 }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]);
236
237 useEffect(() => {
238 if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) {
211 return;239 return;
212 }240 }
213 }, [bootstrapComplete, navigate, routeThreadExists, threadId]);241 finalizePromotedDraftThreadByRef(threadRef);
242 }, [draftThread?.promotedTo, serverThreadStarted, threadRef]);
214 243
215 if (!bootstrapComplete || !routeThreadExists) {244 if (!threadRef || !bootstrapComplete || !routeThreadExists) {
216 return null;245 return null;
217 }246 }
218 247
@@ -222,7 +251,11 @@ function ChatThreadRouteView() { · 3 unmodified lines
222 return (251 return (
223 <>252 <>
224 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">253 <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 />
226 </SidebarInset>259 </SidebarInset>
227 <DiffPanelInlineSidebar260 <DiffPanelInlineSidebar
228 diffOpen={diffOpen}261 diffOpen={diffOpen}
@@ -237,7 +270,11 @@ function ChatThreadRouteView() { · 8 unmodified lines
237 return (270 return (
238 <>271 <>
239 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">272 <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 />
241 </SidebarInset>278 </SidebarInset>
242 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>279 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
243 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}280 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}
@@ -246,7 +283,7 @@ function ChatThreadRouteView() { · 2 unmodified lines
246 );283 );
247}284}
248 285
249export const Route = createFileRoute("/_chat/$threadId")({286export const Route = createFileRoute("/_chat/$environmentId/$threadId")({
250 validateSearch: (search) => parseDiffRouteSearch(search),287 validateSearch: (search) => parseDiffRouteSearch(search),
251 search: {288 search: {
252 middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])],289 middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])],
apps/web/src/routes/_chat.draft.$draftId.tsx+86−0
@@ -0,0 +1,86 @@
1import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect, useMemo } from "react";
3import ChatView from "../components/ChatView";
4import { threadHasStarted } from "../components/ChatView.logic";
5import { useComposerDraftStore, DraftId } from "../composerDraftStore";
6import { SidebarInset } from "../components/ui/sidebar";
7import { createThreadSelectorAcrossEnvironments } from "../storeSelectors";
8import { useStore } from "../store";
9import { buildThreadRouteParams } from "../threadRoutes";
10
11function 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
84export const Route = createFileRoute("/_chat/draft/$draftId")({
85 component: DraftChatThreadRouteView,
86});
apps/web/src/routes/_chat.tsx+17−11
@@ -1,3 +1,4 @@
1import { scopeProjectRef } from "@t3tools/client-runtime";
1import { Outlet, createFileRoute } from "@tanstack/react-router";2import { Outlet, createFileRoute } from "@tanstack/react-router";
2import { useEffect } from "react";3import { useEffect } from "react";
3 4
@@ -12,13 +13,13 @@ import { useServerKeybindings } from "~/rpc/serverState"; · 8 unmodified lines
12 13
13function ChatRouteGlobalShortcuts() {14function ChatRouteGlobalShortcuts() {
14 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);15 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
15 const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size);16 const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size);
16 const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } =17 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
17 useHandleNewThread();18 useHandleNewThread();
18 const keybindings = useServerKeybindings();19 const keybindings = useServerKeybindings();
19 const terminalOpen = useTerminalStateStore((state) =>20 const terminalOpen = useTerminalStateStore((state) =>
20 routeThreadId21 routeThreadRef
21 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen22 ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen
22 : false,23 : false,
23 );24 );
24 const appSettings = useSettings();25 const appSettings = useSettings();
@@ -27,14 +28,18 @@ function ChatRouteGlobalShortcuts() { · 2 unmodified lines
27 const onWindowKeyDown = (event: KeyboardEvent) => {28 const onWindowKeyDown = (event: KeyboardEvent) => {
28 if (event.defaultPrevented) return;29 if (event.defaultPrevented) return;
29 30
30 if (event.key === "Escape" && selectedThreadIdsSize > 0) {31 if (event.key === "Escape" && selectedThreadKeysSize > 0) {
31 event.preventDefault();32 event.preventDefault();
32 clearSelection();33 clearSelection();
33 return;34 return;
34 }35 }
35 36
36 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId;37 const projectRef = activeThread
37 if (!projectId) return;38 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
39 : activeDraftThread && routeThreadRef
40 ? scopeProjectRef(routeThreadRef.environmentId, activeDraftThread.projectId)
41 : defaultProjectRef;
42 if (!projectRef) return;
38 43
39 const command = resolveShortcutCommand(event, keybindings, {44 const command = resolveShortcutCommand(event, keybindings, {
40 context: {45 context: {
@@ -46,7 +51,7 @@ function ChatRouteGlobalShortcuts() { · 5 unmodified lines
46 if (command === "chat.newLocal") {51 if (command === "chat.newLocal") {
47 event.preventDefault();52 event.preventDefault();
48 event.stopPropagation();53 event.stopPropagation();
49 void handleNewThread(projectId, {54 void handleNewThread(projectRef, {
50 envMode: resolveSidebarNewThreadEnvMode({55 envMode: resolveSidebarNewThreadEnvMode({
51 defaultEnvMode: appSettings.defaultThreadEnvMode,56 defaultEnvMode: appSettings.defaultThreadEnvMode,
52 }),57 }),
@@ -57,7 +62,7 @@ function ChatRouteGlobalShortcuts() { · 4 unmodified lines
57 if (command === "chat.new") {62 if (command === "chat.new") {
58 event.preventDefault();63 event.preventDefault();
59 event.stopPropagation();64 event.stopPropagation();
60 void handleNewThread(projectId, {65 void handleNewThread(projectRef, {
61 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,66 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
62 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,67 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
63 envMode:68 envMode:
@@ -77,8 +82,9 @@ function ChatRouteGlobalShortcuts() { · 13 unmodified lines
77 clearSelection,82 clearSelection,
78 handleNewThread,83 handleNewThread,
79 keybindings,84 keybindings,
80 defaultProjectId,85 defaultProjectRef,
81 selectedThreadIdsSize,86 routeThreadRef,
87 selectedThreadKeysSize,
82 terminalOpen,88 terminalOpen,
83 appSettings.defaultThreadEnvMode,89 appSettings.defaultThreadEnvMode,
84 ]);90 ]);
apps/web/src/rpc/serverState.test.ts+18−0
@@ -1,5 +1,6 @@
1import {1import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,
3 ProjectId,4 ProjectId,
4 ThreadId,5 ThreadId,
5 type ServerConfig,6 type ServerConfig,
@@ -50,7 +51,21 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [ · 44 unmodified lines
50 },51 },
51];52];
52 53
54const 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
53const baseServerConfig: ServerConfig = {67const baseServerConfig: ServerConfig = {
68 environment: baseEnvironment,
54 cwd: "/tmp/workspace",69 cwd: "/tmp/workspace",
55 keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",70 keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
56 keybindings: [],71 keybindings: [],
@@ -193,6 +208,7 @@ describe("serverState", () => { · 136 unmodified lines
193 sequence: 1,208 sequence: 1,
194 type: "welcome",209 type: "welcome",
195 payload: {210 payload: {
211 environment: baseEnvironment,
196 cwd: "/tmp/workspace",212 cwd: "/tmp/workspace",
197 projectName: "t3-code",213 projectName: "t3-code",
198 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),214 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
@@ -201,6 +217,7 @@ describe("serverState", () => { · 2 unmodified lines
201 });217 });
202 218
203 expect(listener).toHaveBeenCalledWith({219 expect(listener).toHaveBeenCalledWith({
220 environment: baseEnvironment,
204 cwd: "/tmp/workspace",221 cwd: "/tmp/workspace",
205 projectName: "t3-code",222 projectName: "t3-code",
206 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),223 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
@@ -210,6 +227,7 @@ describe("serverState", () => { · 3 unmodified lines
210 const lateListener = vi.fn();227 const lateListener = vi.fn();
211 const unsubscribeLate = onWelcome(lateListener);228 const unsubscribeLate = onWelcome(lateListener);
212 expect(lateListener).toHaveBeenCalledWith({229 expect(lateListener).toHaveBeenCalledWith({
230 environment: baseEnvironment,
213 cwd: "/tmp/workspace",231 cwd: "/tmp/workspace",
214 projectName: "t3-code",232 projectName: "t3-code",
215 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),233 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
apps/web/src/store.test.ts+166−105
@@ -1,6 +1,7 @@
1import {1import {
2 CheckpointRef,2 CheckpointRef,
3 DEFAULT_MODEL_BY_PROVIDER,3 DEFAULT_MODEL_BY_PROVIDER,
4 EnvironmentId,
4 EventId,5 EventId,
5 MessageId,6 MessageId,
6 ProjectId,7 ProjectId,
@@ -14,16 +15,49 @@ import { describe, expect, it } from "vitest"; · 7 unmodified lines
14import {15import {
15 applyOrchestrationEvent,16 applyOrchestrationEvent,
16 applyOrchestrationEvents,17 applyOrchestrationEvents,
17 selectProjects,18 selectEnvironmentState,
18 selectThreads,19 selectProjectsAcrossEnvironments,
20 selectThreadsAcrossEnvironments,
19 syncServerReadModel,21 syncServerReadModel,
20 type AppState,22 type AppState,
23 type EnvironmentState,
21} from "./store";24} from "./store";
22import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";25import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
23 26
27const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
28
29function 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
24function makeThread(overrides: Partial<Thread> = {}): Thread {57function makeThread(overrides: Partial<Thread> = {}): Thread {
25 return {58 return {
26 id: ThreadId.makeUnsafe("thread-1"),59 id: ThreadId.makeUnsafe("thread-1"),
60 environmentId: localEnvironmentId,
27 codexThreadId: null,61 codexThreadId: null,
28 projectId: ProjectId.makeUnsafe("project-1"),62 projectId: ProjectId.makeUnsafe("project-1"),
29 title: "Thread",63 title: "Thread",
@@ -52,6 +86,7 @@ function makeState(thread: Thread): AppState { · 22 unmodified lines
52 const projectId = ProjectId.makeUnsafe("project-1");86 const projectId = ProjectId.makeUnsafe("project-1");
53 const project = {87 const project = {
54 id: projectId,88 id: projectId,
89 environmentId: localEnvironmentId,
55 name: "Project",90 name: "Project",
56 cwd: "/tmp/project",91 cwd: "/tmp/project",
57 defaultModelSelection: {92 defaultModelSelection: {
@@ -62,10 +97,10 @@ function makeState(thread: Thread): AppState { · 4 unmodified lines
62 updatedAt: "2026-02-13T00:00:00.000Z",97 updatedAt: "2026-02-13T00:00:00.000Z",
63 scripts: [],98 scripts: [],
64 };99 };
65 const threadIdsByProjectId: AppState["threadIdsByProjectId"] = {100 const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = {
66 [thread.projectId]: [thread.id],101 [thread.projectId]: [thread.id],
67 };102 };
68 return {103 const environmentState = {
69 projectIds: [projectId],104 projectIds: [projectId],
70 projectById: {105 projectById: {
71 [projectId]: project,106 [projectId]: project,
@@ -75,6 +110,7 @@ function makeState(thread: Thread): AppState { · 3 unmodified lines
75 threadShellById: {110 threadShellById: {
76 [thread.id]: {111 [thread.id]: {
77 id: thread.id,112 id: thread.id,
113 environmentId: thread.environmentId,
78 codexThreadId: thread.codexThreadId,114 codexThreadId: thread.codexThreadId,
79 projectId: thread.projectId,115 projectId: thread.projectId,
80 title: thread.title,116 title: thread.title,
@@ -106,7 +142,7 @@ function makeState(thread: Thread): AppState { · 25 unmodified lines
106 messageByThreadId: {142 messageByThreadId: {
107 [thread.id]: Object.fromEntries(143 [thread.id]: Object.fromEntries(
108 thread.messages.map((message) => [message.id, message] as const),144 thread.messages.map((message) => [message.id, message] as const),
109 ) as AppState["messageByThreadId"][ThreadId],145 ) as EnvironmentState["messageByThreadId"][ThreadId],
110 },146 },
111 activityIdsByThreadId: {147 activityIdsByThreadId: {
112 [thread.id]: thread.activities.map((activity) => activity.id),148 [thread.id]: thread.activities.map((activity) => activity.id),
@@ -114,7 +150,7 @@ function makeState(thread: Thread): AppState { · 1 unmodified line
114 activityByThreadId: {150 activityByThreadId: {
115 [thread.id]: Object.fromEntries(151 [thread.id]: Object.fromEntries(
116 thread.activities.map((activity) => [activity.id, activity] as const),152 thread.activities.map((activity) => [activity.id, activity] as const),
117 ) as AppState["activityByThreadId"][ThreadId],153 ) as EnvironmentState["activityByThreadId"][ThreadId],
118 },154 },
119 proposedPlanIdsByThreadId: {155 proposedPlanIdsByThreadId: {
120 [thread.id]: thread.proposedPlans.map((plan) => plan.id),156 [thread.id]: thread.proposedPlans.map((plan) => plan.id),
@@ -122,7 +158,7 @@ function makeState(thread: Thread): AppState { · 1 unmodified line
122 proposedPlanByThreadId: {158 proposedPlanByThreadId: {
123 [thread.id]: Object.fromEntries(159 [thread.id]: Object.fromEntries(
124 thread.proposedPlans.map((plan) => [plan.id, plan] as const),160 thread.proposedPlans.map((plan) => [plan.id, plan] as const),
125 ) as AppState["proposedPlanByThreadId"][ThreadId],161 ) as EnvironmentState["proposedPlanByThreadId"][ThreadId],
126 },162 },
127 turnDiffIdsByThreadId: {163 turnDiffIdsByThreadId: {
128 [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId),164 [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId),
@@ -130,15 +166,16 @@ function makeState(thread: Thread): AppState { · 1 unmodified line
130 turnDiffSummaryByThreadId: {166 turnDiffSummaryByThreadId: {
131 [thread.id]: Object.fromEntries(167 [thread.id]: Object.fromEntries(
132 thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const),168 thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const),
133 ) as AppState["turnDiffSummaryByThreadId"][ThreadId],169 ) as EnvironmentState["turnDiffSummaryByThreadId"][ThreadId],
134 },170 },
135 sidebarThreadSummaryById: {},171 sidebarThreadSummaryById: {},
136 bootstrapComplete: true,172 bootstrapComplete: true,
137 };173 };
174 return withActiveEnvironmentState(environmentState);
138}175}
139 176
140function makeEmptyState(overrides: Partial<AppState> = {}): AppState {177function makeEmptyState(overrides: Partial<AppState & EnvironmentState> = {}): AppState {
141 return {178 const environmentState: EnvironmentState = {
142 projectIds: [],179 projectIds: [],
143 projectById: {},180 projectById: {},
144 threadIds: [],181 threadIds: [],
@@ -156,16 +193,20 @@ function makeEmptyState(overrides: Partial<AppState> = {}): AppState { · 11 unmodified lines
156 turnDiffSummaryByThreadId: {},193 turnDiffSummaryByThreadId: {},
157 sidebarThreadSummaryById: {},194 sidebarThreadSummaryById: {},
158 bootstrapComplete: true,195 bootstrapComplete: true,
159 ...overrides,
160 };196 };
197 return withActiveEnvironmentState(environmentState, overrides);
198}
199
200function localEnvironmentStateOf(state: AppState): EnvironmentState {
201 return selectEnvironmentState(state, localEnvironmentId);
161}202}
162 203
163function projectsOf(state: AppState) {204function projectsOf(state: AppState) {
164 return selectProjects(state);205 return selectProjectsAcrossEnvironments(state);
165}206}
166 207
167function threadsOf(state: AppState) {208function threadsOf(state: AppState) {
168 return selectThreads(state);209 return selectThreadsAcrossEnvironments(state);
169}210}
170 211
171function makeEvent<T extends OrchestrationEvent["type"]>(212function makeEvent<T extends OrchestrationEvent["type"]>(
@@ -266,14 +307,20 @@ function makeReadModelProject( · 94 unmodified lines
266 307
267describe("store read model sync", () => {308describe("store read model sync", () => {
268 it("marks bootstrap complete after snapshot sync", () => {309 it("marks bootstrap complete after snapshot sync", () => {
269 const initialState: AppState = {310 const initialState = withActiveEnvironmentState(
270 ...makeState(makeThread()),311 localEnvironmentStateOf(makeState(makeThread())),
271 bootstrapComplete: false,312 {
272 };313 bootstrapComplete: false,
314 },
315 );
273 316
274 const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({})));317 const next = syncServerReadModel(
318 initialState,
319 makeReadModel(makeReadModelThread({})),
320 localEnvironmentId,
321 );
275 322
276 expect(next.bootstrapComplete).toBe(true);323 expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true);
277 });324 });
278 325
279 it("preserves claude model slugs without an active session", () => {326 it("preserves claude model slugs without an active session", () => {
@@ -287,7 +334,7 @@ describe("store read model sync", () => { · 7 unmodified lines
287 }),334 }),
288 );335 );
289 336
290 const next = syncServerReadModel(initialState, readModel);337 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
291 338
292 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6");339 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6");
293 });340 });
@@ -312,7 +359,7 @@ describe("store read model sync", () => { · 18 unmodified lines
312 }),359 }),
313 );360 );
314 361
315 const next = syncServerReadModel(initialState, readModel);362 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
316 363
317 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6");364 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6");
318 });365 });
@@ -325,7 +372,7 @@ describe("store read model sync", () => { · 6 unmodified lines
325 }),372 }),
326 );373 );
327 374
328 const next = syncServerReadModel(initialState, readModel);375 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
329 376
330 expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z");377 expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z");
331 expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z");378 expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z");
@@ -341,6 +388,7 @@ describe("store read model sync", () => { · 9 unmodified lines
341 archivedAt,388 archivedAt,
342 }),389 }),
343 ),390 ),
391 localEnvironmentId,
344 );392 );
345 393
346 expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt);394 expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt);
@@ -355,6 +403,7 @@ describe("store read model sync", () => { · 8 unmodified lines
355 projectById: {403 projectById: {
356 [project2]: {404 [project2]: {
357 id: project2,405 id: project2,
406 environmentId: localEnvironmentId,
358 name: "Project 2",407 name: "Project 2",
359 cwd: "/tmp/project-2",408 cwd: "/tmp/project-2",
360 defaultModelSelection: {409 defaultModelSelection: {
@@ -367,6 +416,7 @@ describe("store read model sync", () => { · 6 unmodified lines
367 },416 },
368 [project1]: {417 [project1]: {
369 id: project1,418 id: project1,
419 environmentId: localEnvironmentId,
370 name: "Project 1",420 name: "Project 1",
371 cwd: "/tmp/project-1",421 cwd: "/tmp/project-1",
372 defaultModelSelection: {422 defaultModelSelection: {
@@ -402,7 +452,7 @@ describe("store read model sync", () => { · 29 unmodified lines
402 threads: [],452 threads: [],
403 };453 };
404 454
405 const next = syncServerReadModel(initialState, readModel);455 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
406 456
407 expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]);457 expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]);
408 });458 });
@@ -410,10 +460,9 @@ describe("store read model sync", () => { · 1 unmodified line
410 460
411describe("incremental orchestration updates", () => {461describe("incremental orchestration updates", () => {
412 it("does not mark bootstrap complete for incremental events", () => {462 it("does not mark bootstrap complete for incremental events", () => {
413 const state: AppState = {463 const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), {
414 ...makeState(makeThread()),
415 bootstrapComplete: false,464 bootstrapComplete: false,
416 };465 });
417 466
418 const next = applyOrchestrationEvent(467 const next = applyOrchestrationEvent(
419 state,468 state,
@@ -422,30 +471,10 @@ describe("incremental orchestration updates", () => { · 2 unmodified lines
422 title: "Updated title",471 title: "Updated title",
423 updatedAt: "2026-02-27T00:00:01.000Z",472 updatedAt: "2026-02-27T00:00:01.000Z",
424 }),473 }),
474 localEnvironmentId,
425 );475 );
426 476
427 expect(next.bootstrapComplete).toBe(false);477 expect(localEnvironmentStateOf(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");
449 });478 });
450 479
451 it("preserves state identity for no-op project and thread deletes", () => {480 it("preserves state identity for no-op project and thread deletes", () => {
@@ -458,6 +487,7 @@ describe("incremental orchestration updates", () => { · 6 unmodified lines
458 projectId: ProjectId.makeUnsafe("project-missing"),487 projectId: ProjectId.makeUnsafe("project-missing"),
459 deletedAt: "2026-02-27T00:00:01.000Z",488 deletedAt: "2026-02-27T00:00:01.000Z",
460 }),489 }),
490 localEnvironmentId,
461 );491 );
462 const nextAfterThreadDelete = applyOrchestrationEvent(492 const nextAfterThreadDelete = applyOrchestrationEvent(
463 state,493 state,
@@ -465,6 +495,7 @@ describe("incremental orchestration updates", () => { · 1 unmodified line
465 threadId: ThreadId.makeUnsafe("thread-missing"),495 threadId: ThreadId.makeUnsafe("thread-missing"),
466 deletedAt: "2026-02-27T00:00:01.000Z",496 deletedAt: "2026-02-27T00:00:01.000Z",
467 }),497 }),
498 localEnvironmentId,
468 );499 );
469 500
470 expect(nextAfterProjectDelete).toBe(state);501 expect(nextAfterProjectDelete).toBe(state);
@@ -479,6 +510,7 @@ describe("incremental orchestration updates", () => { · 8 unmodified lines
479 projectById: {510 projectById: {
480 [originalProjectId]: {511 [originalProjectId]: {
481 id: originalProjectId,512 id: originalProjectId,
513 environmentId: localEnvironmentId,
482 name: "Project",514 name: "Project",
483 cwd: "/tmp/project",515 cwd: "/tmp/project",
484 defaultModelSelection: {516 defaultModelSelection: {
@@ -506,15 +538,18 @@ describe("incremental orchestration updates", () => { · 21 unmodified lines
506 createdAt: "2026-02-27T00:00:01.000Z",538 createdAt: "2026-02-27T00:00:01.000Z",
507 updatedAt: "2026-02-27T00:00:01.000Z",539 updatedAt: "2026-02-27T00:00:01.000Z",
508 }),540 }),
541 localEnvironmentId,
509 );542 );
510 543
511 expect(projectsOf(next)).toHaveLength(1);544 expect(projectsOf(next)).toHaveLength(1);
512 expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId);545 expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId);
513 expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project");546 expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project");
514 expect(projectsOf(next)[0]?.name).toBe("Project Recreated");547 expect(projectsOf(next)[0]?.name).toBe("Project Recreated");
515 expect(next.projectIds).toEqual([recreatedProjectId]);548 expect(localEnvironmentStateOf(next).projectIds).toEqual([recreatedProjectId]);
516 expect(next.projectById[originalProjectId]).toBeUndefined();549 expect(localEnvironmentStateOf(next).projectById[originalProjectId]).toBeUndefined();
517 expect(next.projectById[recreatedProjectId]?.id).toBe(recreatedProjectId);550 expect(localEnvironmentStateOf(next).projectById[recreatedProjectId]?.id).toBe(
551 recreatedProjectId,
552 );
518 });553 });
519 554
520 it("removes stale project index entries when thread.created recreates a thread under a new project", () => {555 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
525 id: threadId,560 id: threadId,
526 projectId: originalProjectId,561 projectId: originalProjectId,
527 });562 });
528 const state: AppState = {563 const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(thread)), {
529 ...makeState(thread),
530 projectIds: [originalProjectId, recreatedProjectId],564 projectIds: [originalProjectId, recreatedProjectId],
531 projectById: {565 projectById: {
532 [originalProjectId]: {566 [originalProjectId]: {
533 id: originalProjectId,567 id: originalProjectId,
568 environmentId: localEnvironmentId,
534 name: "Project 1",569 name: "Project 1",
535 cwd: "/tmp/project-1",570 cwd: "/tmp/project-1",
536 defaultModelSelection: {571 defaultModelSelection: {
@@ -543,6 +578,7 @@ describe("incremental orchestration updates", () => { · 6 unmodified lines
543 },578 },
544 [recreatedProjectId]: {579 [recreatedProjectId]: {
545 id: recreatedProjectId,580 id: recreatedProjectId,
581 environmentId: localEnvironmentId,
546 name: "Project 2",582 name: "Project 2",
547 cwd: "/tmp/project-2",583 cwd: "/tmp/project-2",
548 defaultModelSelection: {584 defaultModelSelection: {
@@ -554,7 +590,7 @@ describe("incremental orchestration updates", () => { · 5 unmodified lines
554 scripts: [],590 scripts: [],
555 },591 },
556 },592 },
557 };593 });
558 594
559 const next = applyOrchestrationEvent(595 const next = applyOrchestrationEvent(
560 state,596 state,
@@ -573,12 +609,15 @@ describe("incremental orchestration updates", () => { · 12 unmodified lines
573 createdAt: "2026-02-27T00:00:01.000Z",609 createdAt: "2026-02-27T00:00:01.000Z",
574 updatedAt: "2026-02-27T00:00:01.000Z",610 updatedAt: "2026-02-27T00:00:01.000Z",
575 }),611 }),
612 localEnvironmentId,
576 );613 );
577 614
578 expect(threadsOf(next)).toHaveLength(1);615 expect(threadsOf(next)).toHaveLength(1);
579 expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId);616 expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId);
580 expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined();617 expect(localEnvironmentStateOf(next).threadIdsByProjectId[originalProjectId]).toBeUndefined();
581 expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]);618 expect(localEnvironmentStateOf(next).threadIdsByProjectId[recreatedProjectId]).toEqual([
619 threadId,
620 ]);
582 });621 });
583 622
584 it("updates only the affected thread for message events", () => {623 it("updates only the affected thread for message events", () => {
@@ -597,13 +636,15 @@ describe("incremental orchestration updates", () => { · 12 unmodified lines
597 ],636 ],
598 });637 });
599 const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") });638 const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") });
600 const state: AppState = {639 const baseState = makeState(thread1);
601 ...makeState(thread1),640 const baseEnvironmentState = localEnvironmentStateOf(baseState);
641 const state = withActiveEnvironmentState(baseEnvironmentState, {
602 threadIds: [thread1.id, thread2.id],642 threadIds: [thread1.id, thread2.id],
603 threadShellById: {643 threadShellById: {
604 ...makeState(thread1).threadShellById,644 ...baseEnvironmentState.threadShellById,
605 [thread2.id]: {645 [thread2.id]: {
606 id: thread2.id,646 id: thread2.id,
647 environmentId: thread2.environmentId,
607 codexThreadId: thread2.codexThreadId,648 codexThreadId: thread2.codexThreadId,
608 projectId: thread2.projectId,649 projectId: thread2.projectId,
609 title: thread2.title,650 title: thread2.title,
@@ -619,54 +660,54 @@ describe("incremental orchestration updates", () => { · 9 unmodified lines
619 },660 },
620 },661 },
621 threadSessionById: {662 threadSessionById: {
622 ...makeState(thread1).threadSessionById,663 ...baseEnvironmentState.threadSessionById,
623 [thread2.id]: thread2.session,664 [thread2.id]: thread2.session,
624 },665 },
625 threadTurnStateById: {666 threadTurnStateById: {
626 ...makeState(thread1).threadTurnStateById,667 ...baseEnvironmentState.threadTurnStateById,
627 [thread2.id]: {668 [thread2.id]: {
628 latestTurn: thread2.latestTurn,669 latestTurn: thread2.latestTurn,
629 },670 },
630 },671 },
631 messageIdsByThreadId: {672 messageIdsByThreadId: {
632 ...makeState(thread1).messageIdsByThreadId,673 ...baseEnvironmentState.messageIdsByThreadId,
633 [thread2.id]: [],674 [thread2.id]: [],
634 },675 },
635 messageByThreadId: {676 messageByThreadId: {
636 ...makeState(thread1).messageByThreadId,677 ...baseEnvironmentState.messageByThreadId,
637 [thread2.id]: {},678 [thread2.id]: {},
638 },679 },
639 activityIdsByThreadId: {680 activityIdsByThreadId: {
640 ...makeState(thread1).activityIdsByThreadId,681 ...baseEnvironmentState.activityIdsByThreadId,
641 [thread2.id]: [],682 [thread2.id]: [],
642 },683 },
643 activityByThreadId: {684 activityByThreadId: {
644 ...makeState(thread1).activityByThreadId,685 ...baseEnvironmentState.activityByThreadId,
645 [thread2.id]: {},686 [thread2.id]: {},
646 },687 },
647 proposedPlanIdsByThreadId: {688 proposedPlanIdsByThreadId: {
648 ...makeState(thread1).proposedPlanIdsByThreadId,689 ...baseEnvironmentState.proposedPlanIdsByThreadId,
649 [thread2.id]: [],690 [thread2.id]: [],
650 },691 },
651 proposedPlanByThreadId: {692 proposedPlanByThreadId: {
652 ...makeState(thread1).proposedPlanByThreadId,693 ...baseEnvironmentState.proposedPlanByThreadId,
653 [thread2.id]: {},694 [thread2.id]: {},
654 },695 },
655 turnDiffIdsByThreadId: {696 turnDiffIdsByThreadId: {
656 ...makeState(thread1).turnDiffIdsByThreadId,697 ...baseEnvironmentState.turnDiffIdsByThreadId,
657 [thread2.id]: [],698 [thread2.id]: [],
658 },699 },
659 turnDiffSummaryByThreadId: {700 turnDiffSummaryByThreadId: {
660 ...makeState(thread1).turnDiffSummaryByThreadId,701 ...baseEnvironmentState.turnDiffSummaryByThreadId,
661 [thread2.id]: {},702 [thread2.id]: {},
662 },703 },
663 sidebarThreadSummaryById: {704 sidebarThreadSummaryById: {
664 ...makeState(thread1).sidebarThreadSummaryById,705 ...baseEnvironmentState.sidebarThreadSummaryById,
665 },706 },
666 threadIdsByProjectId: {707 threadIdsByProjectId: {
667 [thread1.projectId]: [thread1.id, thread2.id],708 [thread1.projectId]: [thread1.id, thread2.id],
668 },709 },
669 };710 });
670 711
671 const next = applyOrchestrationEvent(712 const next = applyOrchestrationEvent(
672 state,713 state,
@@ -680,14 +721,25 @@ describe("incremental orchestration updates", () => { · 7 unmodified lines
680 createdAt: "2026-02-27T00:00:01.000Z",721 createdAt: "2026-02-27T00:00:01.000Z",
681 updatedAt: "2026-02-27T00:00:01.000Z",722 updatedAt: "2026-02-27T00:00:01.000Z",
682 }),723 }),
724 localEnvironmentId,
683 );725 );
684 726
685 expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world");727 expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world");
686 expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running");728 expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running");
687 expect(next.threadShellById[thread2.id]).toBe(state.threadShellById[thread2.id]);729 const nextEnvironmentState = next.environmentStateById[localEnvironmentId];
688 expect(next.threadSessionById[thread2.id]).toBe(state.threadSessionById[thread2.id]);730 const previousEnvironmentState = state.environmentStateById[localEnvironmentId];
689 expect(next.messageIdsByThreadId[thread2.id]).toBe(state.messageIdsByThreadId[thread2.id]);731 expect(nextEnvironmentState?.threadShellById[thread2.id]).toBe(
690 expect(next.messageByThreadId[thread2.id]).toBe(state.messageByThreadId[thread2.id]);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 );
691 });743 });
692 744
693 it("applies replay batches in sequence and updates session state", () => {745 it("applies replay batches in sequence and updates session state", () => {
@@ -703,38 +755,42 @@ describe("incremental orchestration updates", () => { · 9 unmodified lines
703 });755 });
704 const state = makeState(thread);756 const state = makeState(thread);
705 757
706 const next = applyOrchestrationEvents(state, [758 const next = applyOrchestrationEvents(
707 makeEvent(759 state,
708 "thread.session-set",760 [
709 {761 makeEvent(
710 threadId: thread.id,762 "thread.session-set",
711 session: {763 {
712 threadId: thread.id,764 threadId: thread.id,
713 status: "running",765 session: {
714 providerName: "codex",766 threadId: thread.id,
715 runtimeMode: "full-access",767 status: "running",
716 activeTurnId: TurnId.makeUnsafe("turn-1"),768 providerName: "codex",
717 lastError: null,769 runtimeMode: "full-access",
718 updatedAt: "2026-02-27T00:00:02.000Z",770 activeTurnId: TurnId.makeUnsafe("turn-1"),
771 lastError: null,
772 updatedAt: "2026-02-27T00:00:02.000Z",
773 },
719 },774 },
720 },775 { sequence: 2 },
721 { sequence: 2 },776 ),
722 ),777 makeEvent(
723 makeEvent(778 "thread.message-sent",
724 "thread.message-sent",779 {
725 {780 threadId: thread.id,
726 threadId: thread.id,781 messageId: MessageId.makeUnsafe("assistant-1"),
727 messageId: MessageId.makeUnsafe("assistant-1"),782 role: "assistant",
728 role: "assistant",783 text: "done",
729 text: "done",784 turnId: TurnId.makeUnsafe("turn-1"),
730 turnId: TurnId.makeUnsafe("turn-1"),785 streaming: false,
731 streaming: false,786 createdAt: "2026-02-27T00:00:03.000Z",
732 createdAt: "2026-02-27T00:00:03.000Z",787 updatedAt: "2026-02-27T00:00:03.000Z",
733 updatedAt: "2026-02-27T00:00:03.000Z",788 },
734 },789 { sequence: 3 },
735 { sequence: 3 },790 ),
736 ),791 ],
737 ]);792 localEnvironmentId,
793 );
738 794
739 expect(threadsOf(next)[0]?.session?.status).toBe("running");795 expect(threadsOf(next)[0]?.session?.status).toBe("running");
740 expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed");796 expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed");
@@ -767,6 +823,7 @@ describe("incremental orchestration updates", () => { · 26 unmodified lines
767 assistantMessageId: MessageId.makeUnsafe("assistant-1"),823 assistantMessageId: MessageId.makeUnsafe("assistant-1"),
768 completedAt: "2026-02-27T00:00:04.000Z",824 completedAt: "2026-02-27T00:00:04.000Z",
769 }),825 }),
826 localEnvironmentId,
770 );827 );
771 828
772 expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1);829 expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1);
@@ -811,6 +868,7 @@ describe("incremental orchestration updates", () => { · 38 unmodified lines
811 createdAt: "2026-02-27T00:00:03.000Z",868 createdAt: "2026-02-27T00:00:03.000Z",
812 updatedAt: "2026-02-27T00:00:03.000Z",869 updatedAt: "2026-02-27T00:00:03.000Z",
813 }),870 }),
871 localEnvironmentId,
814 );872 );
815 873
816 expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe(874 expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe(
@@ -920,6 +978,7 @@ describe("incremental orchestration updates", () => { · 103 unmodified lines
920 threadId: ThreadId.makeUnsafe("thread-1"),978 threadId: ThreadId.makeUnsafe("thread-1"),
921 turnCount: 1,979 turnCount: 1,
922 }),980 }),
981 localEnvironmentId,
923 );982 );
924 983
925 expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([984 expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([
@@ -978,6 +1037,7 @@ describe("incremental orchestration updates", () => { · 52 unmodified lines
978 threadId: thread.id,1037 threadId: thread.id,
979 turnCount: 1,1038 turnCount: 1,
980 }),1039 }),
1040 localEnvironmentId,
981 );1041 );
982 1042
983 expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined();1043 expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined();
@@ -996,6 +1056,7 @@ describe("incremental orchestration updates", () => { · 12 unmodified lines
996 updatedAt: "2026-02-27T00:00:04.000Z",1056 updatedAt: "2026-02-27T00:00:04.000Z",
997 },1057 },
998 }),1058 }),
1059 localEnvironmentId,
999 );1060 );
1000 1061
1001 expect(threadsOf(next)[0]?.latestTurn).toMatchObject({1062 expect(threadsOf(next)[0]?.latestTurn).toMatchObject({
apps/web/src/store.ts+336−92
@@ -1,4 +1,5 @@
1import {1import {
2 type EnvironmentId,
2 type MessageId,3 type MessageId,
3 type OrchestrationCheckpointSummary,4 type OrchestrationCheckpointSummary,
4 type OrchestrationEvent,5 type OrchestrationEvent,
@@ -11,6 +12,8 @@ import { · 6 unmodified lines
11 type OrchestrationThreadActivity,12 type OrchestrationThreadActivity,
12 type ProjectId,13 type ProjectId,
13 type ProviderKind,14 type ProviderKind,
15 type ScopedProjectRef,
16 type ScopedThreadRef,
14 ThreadId,17 ThreadId,
15 type TurnId,18 type TurnId,
16} from "@t3tools/contracts";19} from "@t3tools/contracts";
@@ -35,7 +38,7 @@ import { · 18 unmodified lines
35} from "./types";38} from "./types";
36import { sanitizeThreadErrorMessage } from "./rpc/transportError";39import { sanitizeThreadErrorMessage } from "./rpc/transportError";
37 40
38export interface AppState {41export interface EnvironmentState {
39 projectIds: ProjectId[];42 projectIds: ProjectId[];
40 projectById: Record<ProjectId, Project>;43 projectById: Record<ProjectId, Project>;
41 threadIds: ThreadId[];44 threadIds: ThreadId[];
@@ -55,7 +58,12 @@ export interface AppState { · 13 unmodified lines
55 bootstrapComplete: boolean;58 bootstrapComplete: boolean;
56}59}
57 60
58const initialState: AppState = {61export interface AppState {
62 activeEnvironmentId: EnvironmentId | null;
63 environmentStateById: Record<string, EnvironmentState>;
64}
65
66const initialEnvironmentState: EnvironmentState = {
59 projectIds: [],67 projectIds: [],
60 projectById: {},68 projectById: {},
61 threadIds: [],69 threadIds: [],
@@ -75,6 +83,11 @@ const initialState: AppState = { · 13 unmodified lines
75 bootstrapComplete: false,83 bootstrapComplete: false,
76};84};
77 85
86const initialState: AppState = {
87 activeEnvironmentId: null,
88 environmentStateById: {},
89};
90
78const MAX_THREAD_MESSAGES = 2_000;91const MAX_THREAD_MESSAGES = 2_000;
79const MAX_THREAD_CHECKPOINTS = 500;92const MAX_THREAD_CHECKPOINTS = 500;
80const MAX_THREAD_PROPOSED_PLANS = 200;93const MAX_THREAD_PROPOSED_PLANS = 200;
@@ -169,11 +182,16 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif · 88 unmodified lines
169 };182 };
170}183}
171 184
172function mapProject(project: OrchestrationReadModel["projects"][number]): Project {185function mapProject(
186 project: OrchestrationReadModel["projects"][number],
187 environmentId: EnvironmentId,
188): Project {
173 return {189 return {
174 id: project.id,190 id: project.id,
191 environmentId,
175 name: project.title,192 name: project.title,
176 cwd: project.workspaceRoot,193 cwd: project.workspaceRoot,
194 repositoryIdentity: project.repositoryIdentity ?? null,
177 defaultModelSelection: project.defaultModelSelection195 defaultModelSelection: project.defaultModelSelection
178 ? normalizeModelSelection(project.defaultModelSelection)196 ? normalizeModelSelection(project.defaultModelSelection)
179 : null,197 : null,
@@ -183,9 +201,10 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec · 3 unmodified lines
183 };201 };
184}202}
185 203
186function mapThread(thread: OrchestrationThread): Thread {204function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread {
187 return {205 return {
188 id: thread.id,206 id: thread.id,
207 environmentId,
189 codexThreadId: null,208 codexThreadId: null,
190 projectId: thread.projectId,209 projectId: thread.projectId,
191 title: thread.title,210 title: thread.title,
@@ -211,6 +230,7 @@ function mapThread(thread: OrchestrationThread): Thread { · 19 unmodified lines
211function toThreadShell(thread: Thread): ThreadShell {230function toThreadShell(thread: Thread): ThreadShell {
212 return {231 return {
213 id: thread.id,232 id: thread.id,
233 environmentId: thread.environmentId,
214 codexThreadId: thread.codexThreadId,234 codexThreadId: thread.codexThreadId,
215 projectId: thread.projectId,235 projectId: thread.projectId,
216 title: thread.title,236 title: thread.title,
@@ -251,6 +271,7 @@ function getLatestUserMessageAt(messages: ReadonlyArray<ChatMessage>): string | · 34 unmodified lines
251function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary {271function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary {
252 return {272 return {
253 id: thread.id,273 id: thread.id,
274 environmentId: thread.environmentId,
254 projectId: thread.projectId,275 projectId: thread.projectId,
255 title: thread.title,276 title: thread.title,
256 interactionMode: thread.interactionMode,277 interactionMode: thread.interactionMode,
@@ -298,6 +319,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b · 41 unmodified lines
298 return (319 return (
299 left !== undefined &&320 left !== undefined &&
300 left.id === right.id &&321 left.id === right.id &&
322 left.environmentId === right.environmentId &&
301 left.codexThreadId === right.codexThreadId &&323 left.codexThreadId === right.codexThreadId &&
302 left.projectId === right.projectId &&324 left.projectId === right.projectId &&
303 left.title === right.title &&325 left.title === right.title &&
@@ -377,7 +399,7 @@ function buildTurnDiffSlice(thread: Thread): { · 73 unmodified lines
377 };399 };
378}400}
379 401
380function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] {402function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] {
381 const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS;403 const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS;
382 const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP;404 const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP;
383 if (ids.length === 0) {405 if (ids.length === 0) {
@@ -390,7 +412,7 @@ function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[ · 6 unmodified lines
390}412}
391 413
392function selectThreadActivities(414function selectThreadActivities(
393 state: AppState,415 state: EnvironmentState,
394 threadId: ThreadId,416 threadId: ThreadId,
395): OrchestrationThreadActivity[] {417): OrchestrationThreadActivity[] {
396 const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS;418 const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS;
@@ -404,7 +426,7 @@ function selectThreadActivities( · 7 unmodified lines
404 });426 });
405}427}
406 428
407function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] {429function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] {
408 const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS;430 const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS;
409 const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP;431 const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP;
410 if (ids.length === 0) {432 if (ids.length === 0) {
@@ -416,7 +438,10 @@ function selectThreadProposedPlans(state: AppState, threadId: ThreadId): Propose · 5 unmodified lines
416 });438 });
417}439}
418 440
419function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] {441function selectThreadTurnDiffSummaries(
442 state: EnvironmentState,
443 threadId: ThreadId,
444): TurnDiffSummary[] {
420 const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS;445 const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS;
421 const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP;446 const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP;
422 if (ids.length === 0) {447 if (ids.length === 0) {
@@ -428,7 +453,7 @@ function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): Tur · 5 unmodified lines
428 });453 });
429}454}
430 455
431function getThread(state: AppState, threadId: ThreadId): Thread | undefined {456function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined {
432 const shell = state.threadShellById[threadId];457 const shell = state.threadShellById[threadId];
433 if (!shell) {458 if (!shell) {
434 return undefined;459 return undefined;
@@ -446,21 +471,25 @@ function getThread(state: AppState, threadId: ThreadId): Thread | undefined { · 11 unmodified lines
446 };471 };
447}472}
448 473
449function getProjects(state: AppState): Project[] {474function getProjects(state: EnvironmentState): Project[] {
450 return state.projectIds.flatMap((projectId) => {475 return state.projectIds.flatMap((projectId) => {
451 const project = state.projectById[projectId];476 const project = state.projectById[projectId];
452 return project ? [project] : [];477 return project ? [project] : [];
453 });478 });
454}479}
455 480
456function getThreads(state: AppState): Thread[] {481function getThreads(state: EnvironmentState): Thread[] {
457 return state.threadIds.flatMap((threadId) => {482 return state.threadIds.flatMap((threadId) => {
458 const thread = getThread(state, threadId);483 const thread = getThread(state, threadId);
459 return thread ? [thread] : [];484 return thread ? [thread] : [];
460 });485 });
461}486}
462 487
463function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState {488function writeThreadState(
489 state: EnvironmentState,
490 nextThread: Thread,
491 previousThread?: Thread,
492): EnvironmentState {
464 const nextShell = toThreadShell(nextThread);493 const nextShell = toThreadShell(nextThread);
465 const nextTurnState = toThreadTurnState(nextThread);494 const nextTurnState = toThreadTurnState(nextThread);
466 const previousShell = state.threadShellById[nextThread.id];495 const previousShell = state.threadShellById[nextThread.id];
@@ -613,7 +642,7 @@ function writeThreadState(state: AppState, nextThread: Thread, previousThread?: · 146 unmodified lines
613 return nextState;642 return nextState;
614}643}
615 644
616function removeThreadState(state: AppState, threadId: ThreadId): AppState {645function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState {
617 const shell = state.threadShellById[threadId];646 const shell = state.threadShellById[threadId];
618 if (!shell) {647 if (!shell) {
619 return state;648 return state;
@@ -887,10 +916,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string { · 267 unmodified lines
887}916}
888 917
889function updateThreadState(918function updateThreadState(
890 state: AppState,919 state: EnvironmentState,
891 threadId: ThreadId,920 threadId: ThreadId,
892 updater: (thread: Thread) => Thread,921 updater: (thread: Thread) => Thread,
893): AppState {922): EnvironmentState {
894 const currentThread = getThread(state, threadId);923 const currentThread = getThread(state, threadId);
895 if (!currentThread) {924 if (!currentThread) {
896 return state;925 return state;
@@ -904,7 +933,7 @@ function updateThreadState( · 7 unmodified lines
904 933
905function buildProjectState(934function buildProjectState(
906 projects: ReadonlyArray<Project>,935 projects: ReadonlyArray<Project>,
907): Pick<AppState, "projectIds" | "projectById"> {936): Pick<EnvironmentState, "projectIds" | "projectById"> {
908 return {937 return {
909 projectIds: projects.map((project) => project.id),938 projectIds: projects.map((project) => project.id),
910 projectById: Object.fromEntries(939 projectById: Object.fromEntries(
@@ -916,7 +945,7 @@ function buildProjectState( · 5 unmodified lines
916function buildThreadState(945function buildThreadState(
917 threads: ReadonlyArray<Thread>,946 threads: ReadonlyArray<Thread>,
918): Pick<947): Pick<
919 AppState,948 EnvironmentState,
920 | "threadIds"949 | "threadIds"
921 | "threadIdsByProjectId"950 | "threadIdsByProjectId"
922 | "threadShellById"951 | "threadShellById"
@@ -989,11 +1018,48 @@ function buildThreadState( · 66 unmodified lines
989 };1018 };
990}1019}
991 1020
992export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState {1021function getStoredEnvironmentState(
1022 state: AppState,
1023 environmentId: EnvironmentId,
1024): EnvironmentState {
1025 return state.environmentStateById[environmentId] ?? initialEnvironmentState;
1026}
1027
1028function 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
1052function syncEnvironmentReadModel(
1053 state: EnvironmentState,
1054 readModel: OrchestrationReadModel,
1055 environmentId: EnvironmentId,
1056): EnvironmentState {
993 const projects = readModel.projects1057 const projects = readModel.projects
994 .filter((project) => project.deletedAt === null)1058 .filter((project) => project.deletedAt === null)
995 .map(mapProject);1059 .map((project) => mapProject(project, environmentId));
996 const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread);1060 const threads = readModel.threads
1061 .filter((thread) => thread.deletedAt === null)
1062 .map((thread) => mapThread(thread, environmentId));
997 return {1063 return {
998 ...state,1064 ...state,
999 ...buildProjectState(projects),1065 ...buildProjectState(projects),
@@ -1002,19 +1068,43 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea · 2 unmodified lines
1002 };1068 };
1003}1069}
1004 1070
1005export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState {1071export 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
1087function applyEnvironmentOrchestrationEvent(
1088 state: EnvironmentState,
1089 event: OrchestrationEvent,
1090 environmentId: EnvironmentId,
1091): EnvironmentState {
1006 switch (event.type) {1092 switch (event.type) {
1007 case "project.created": {1093 case "project.created": {
1008 const nextProject = mapProject({1094 const nextProject = mapProject(
1009 id: event.payload.projectId,1095 {
1010 title: event.payload.title,1096 id: event.payload.projectId,
1011 workspaceRoot: event.payload.workspaceRoot,1097 title: event.payload.title,
1012 defaultModelSelection: event.payload.defaultModelSelection,1098 workspaceRoot: event.payload.workspaceRoot,
1013 scripts: event.payload.scripts,1099 repositoryIdentity: event.payload.repositoryIdentity ?? null,
1014 createdAt: event.payload.createdAt,1100 defaultModelSelection: event.payload.defaultModelSelection,
1015 updatedAt: event.payload.updatedAt,1101 scripts: event.payload.scripts,
1016 deletedAt: null,1102 createdAt: event.payload.createdAt,
1017 });1103 updatedAt: event.payload.updatedAt,
1104 deletedAt: null,
1105 },
1106 environmentId,
1107 );
1018 const existingProjectId =1108 const existingProjectId =
1019 state.projectIds.find(1109 state.projectIds.find(
1020 (projectId) =>1110 (projectId) =>
@@ -1060,6 +1150,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve · 39 unmodified lines
1060 ...project,1150 ...project,
1061 ...(event.payload.title !== undefined ? { name: event.payload.title } : {}),1151 ...(event.payload.title !== undefined ? { name: event.payload.title } : {}),
1062 ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}),1152 ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}),
1153 ...(event.payload.repositoryIdentity !== undefined
1154 ? { repositoryIdentity: event.payload.repositoryIdentity ?? null }
1155 : {}),
1063 ...(event.payload.defaultModelSelection !== undefined1156 ...(event.payload.defaultModelSelection !== undefined
1064 ? {1157 ? {
1065 defaultModelSelection: event.payload.defaultModelSelection1158 defaultModelSelection: event.payload.defaultModelSelection
@@ -1095,26 +1188,29 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve · 29 unmodified lines
1095 1188
1096 case "thread.created": {1189 case "thread.created": {
1097 const previousThread = getThread(state, event.payload.threadId);1190 const previousThread = getThread(state, event.payload.threadId);
1098 const nextThread = mapThread({1191 const nextThread = mapThread(
1099 id: event.payload.threadId,1192 {
1100 projectId: event.payload.projectId,1193 id: event.payload.threadId,
1101 title: event.payload.title,1194 projectId: event.payload.projectId,
1102 modelSelection: event.payload.modelSelection,1195 title: event.payload.title,
1103 runtimeMode: event.payload.runtimeMode,1196 modelSelection: event.payload.modelSelection,
1104 interactionMode: event.payload.interactionMode,1197 runtimeMode: event.payload.runtimeMode,
1105 branch: event.payload.branch,1198 interactionMode: event.payload.interactionMode,
1106 worktreePath: event.payload.worktreePath,1199 branch: event.payload.branch,
1107 latestTurn: null,1200 worktreePath: event.payload.worktreePath,
1108 createdAt: event.payload.createdAt,1201 latestTurn: null,
1109 updatedAt: event.payload.updatedAt,1202 createdAt: event.payload.createdAt,
1110 archivedAt: null,1203 updatedAt: event.payload.updatedAt,
1111 deletedAt: null,1204 archivedAt: null,
1112 messages: [],1205 deletedAt: null,
1113 proposedPlans: [],1206 messages: [],
1114 activities: [],1207 proposedPlans: [],
1115 checkpoints: [],1208 activities: [],
1116 session: null,1209 checkpoints: [],
1117 });1210 session: null,
1211 },
1212 environmentId,
1213 );
1118 return writeThreadState(state, nextThread, previousThread);1214 return writeThreadState(state, nextThread, previousThread);
1119 }1215 }
1120 1216
@@ -1481,70 +1577,218 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve · 360 unmodified lines
1481export function applyOrchestrationEvents(1577export function applyOrchestrationEvents(
1482 state: AppState,1578 state: AppState,
1483 events: ReadonlyArray<OrchestrationEvent>,1579 events: ReadonlyArray<OrchestrationEvent>,
1580 environmentId: EnvironmentId,
1484): AppState {1581): AppState {
1485 if (events.length === 0) {1582 if (events.length === 0) {
1486 return state;1583 return state;
1487 }1584 }
1488 return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state);1585 const currentEnvironmentState = getStoredEnvironmentState(state, environmentId);
1489}1586 const nextEnvironmentState = events.reduce(
1490 1587 (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId),
1491export const selectProjects = (state: AppState): Project[] => getProjects(state);1588 currentEnvironmentState,
1492export const selectThreads = (state: AppState): Thread[] => getThreads(state);1589 );
1493export const selectProjectById =1590 return commitEnvironmentState(state, environmentId, nextEnvironmentState);
1494 (projectId: Project["id"] | null | undefined) =>1591}
1495 (state: AppState): Project | undefined =>
1496 projectId ? state.projectById[projectId] : undefined;
1497export const selectThreadById =
1498 (threadId: ThreadId | null | undefined) =>
1499 (state: AppState): Thread | undefined =>
1500 threadId ? getThread(state, threadId) : undefined;
1501export const selectSidebarThreadSummaryById =
1502 (threadId: ThreadId | null | undefined) =>
1503 (state: AppState): SidebarThreadSummary | undefined =>
1504 threadId ? state.sidebarThreadSummaryById[threadId] : undefined;
1505export const selectThreadIdsByProjectId =
1506 (projectId: ProjectId | null | undefined) =>
1507 (state: AppState): ThreadId[] =>
1508 projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS;
1509 1592
1510export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {1593function getEnvironmentEntries(
1511 return updateThreadState(state, threadId, (thread) => {1594 state: AppState,
1512 if (thread.error === error) return thread;1595): ReadonlyArray<readonly [EnvironmentId, EnvironmentState]> {
1513 return { ...thread, error };1596 return Object.entries(state.environmentStateById) as unknown as ReadonlyArray<
1597 readonly [EnvironmentId, EnvironmentState]
1598 >;
1599}
1600
1601export function selectEnvironmentState(
1602 state: AppState,
1603 environmentId: EnvironmentId | null | undefined,
1604): EnvironmentState {
1605 return environmentId ? getStoredEnvironmentState(state, environmentId) : initialEnvironmentState;
1606}
1607
1608export function selectProjectsForEnvironment(
1609 state: AppState,
1610 environmentId: EnvironmentId | null | undefined,
1611): Project[] {
1612 return getProjects(selectEnvironmentState(state, environmentId));
1613}
1614
1615export function selectThreadsForEnvironment(
1616 state: AppState,
1617 environmentId: EnvironmentId | null | undefined,
1618): Thread[] {
1619 return getThreads(selectEnvironmentState(state, environmentId));
1620}
1621
1622export function selectProjectsAcrossEnvironments(state: AppState): Project[] {
1623 return getEnvironmentEntries(state).flatMap(([, environmentState]) =>
1624 getProjects(environmentState),
1625 );
1626}
1627
1628export function selectThreadsAcrossEnvironments(state: AppState): Thread[] {
1629 return getEnvironmentEntries(state).flatMap(([, environmentState]) =>
1630 getThreads(environmentState),
1631 );
1632}
1633
1634export 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
1643export 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] : [];
1514 });1656 });
1515}1657}
1516 1658
1659export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean {
1660 return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete;
1661}
1662
1663export 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
1672export 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
1681export 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
1690export 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
1700export 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
1716export 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
1732export 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
1517export function setThreadBranch(1743export function setThreadBranch(
1518 state: AppState,1744 state: AppState,
1519 threadId: ThreadId,1745 threadId: ThreadId,
1520 branch: string | null,1746 branch: string | null,
1521 worktreePath: string | null,1747 worktreePath: string | null,
1522): AppState {1748): AppState {
1523 return updateThreadState(state, threadId, (thread) => {1749 if (state.activeEnvironmentId === null) {
1524 if (thread.branch === branch && thread.worktreePath === worktreePath) return thread;1750 return state;
1525 const cwdChanged = thread.worktreePath !== worktreePath;1751 }
1526 return {1752
1527 ...thread,1753 const nextEnvironmentState = updateThreadState(
1528 branch,1754 getStoredEnvironmentState(state, state.activeEnvironmentId),
1529 worktreePath,1755 threadId,
1530 ...(cwdChanged ? { session: null } : {}),1756 (thread) => {
1531 };1757 if (thread.branch === branch && thread.worktreePath === worktreePath) return thread;
1532 });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);
1533}1768}
1534 1769
1535interface AppStore extends AppState {1770interface AppStore extends AppState {
1536 syncServerReadModel: (readModel: OrchestrationReadModel) => void;1771 setActiveEnvironmentId: (environmentId: EnvironmentId) => void;
1537 applyOrchestrationEvent: (event: OrchestrationEvent) => void;1772 syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void;
1538 applyOrchestrationEvents: (events: ReadonlyArray<OrchestrationEvent>) => void;1773 applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void;
1774 applyOrchestrationEvents: (
1775 events: ReadonlyArray<OrchestrationEvent>,
1776 environmentId: EnvironmentId,
1777 ) => void;
1539 setError: (threadId: ThreadId, error: string | null) => void;1778 setError: (threadId: ThreadId, error: string | null) => void;
1540 setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void;1779 setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void;
1541}1780}
1542 1781
1543export const useStore = create<AppStore>((set) => ({1782export const useStore = create<AppStore>((set) => ({
1544 ...initialState,1783 ...initialState,
1545 syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)),1784 setActiveEnvironmentId: (environmentId) =>
1546 applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)),1785 set((state) => setActiveEnvironmentId(state, environmentId)),
1547 applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)),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)),
1548 setError: (threadId, error) => set((state) => setError(state, threadId, error)),1792 setError: (threadId, error) => set((state) => setError(state, threadId, error)),
1549 setThreadBranch: (threadId, branch, worktreePath) =>1793 setThreadBranch: (threadId, branch, worktreePath) =>
1550 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),1794 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
apps/web/src/storeSelectors.ts+68−27
@@ -1,5 +1,11 @@
1import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts";1import {
2import { type AppState } from "./store";2 type MessageId,
3 type ScopedProjectRef,
4 type ScopedThreadRef,
5 type ThreadId,
6 type TurnId,
7} from "@t3tools/contracts";
8import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store";
3import {9import {
4 type ChatMessage,10 type ChatMessage,
5 type Project,11 type Project,
@@ -30,54 +36,61 @@ function collectByIds<TKey extends string, TValue>( · 24 unmodified lines
30 });36 });
31}37}
32 38
33export function createProjectSelector(39export function createProjectSelectorByRef(
34 projectId: ProjectId | null | undefined,40 ref: ScopedProjectRef | null | undefined,
35): (state: AppState) => Project | undefined {41): (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.