authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-08 19:05:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 02:05:20+00:00
logb96308fc5adba6eecae5ee26efcfc8a01caaafd2
tree3d7152b186f1f3f8fcad76bc3975b65644172c3b
parent72b7f90c5010cc43af87125b3e845a04589b7e5b
signature Signed by PGP key B5690EEEBB952194

Prepare datamodel for multi-environment (#1765)

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

123 files changed, 10829 insertions(+), 5592 deletions(-)

apps/desktop/src/main.ts+9
...@@ -60,6 +60,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";...@@ -60,6 +60,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
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 {...@@ -1172,6 +1173,14 @@ function registerIpcHandlers(): void {
1172 event.returnValue = backendWsUrl;1173 event.returnValue = backendWsUrl;
1173 });1174 });
11741175
1176 ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
1177 ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
1178 event.returnValue = {
1179 label: "Local environment",
1180 wsUrl: backendWsUrl || null,
1181 } as const;
1182 });
1183
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
...@@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check";...@@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check";
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";
1617
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
...@@ -45,6 +45,7 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";...@@ -45,6 +45,7 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";
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 = (...@@ -338,6 +339,7 @@ export const makeOrchestrationIntegrationHarness = (
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
...@@ -30,6 +30,7 @@ export interface ServerDerivedPaths {...@@ -30,6 +30,7 @@ export interface ServerDerivedPaths {
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}
3435
35/**36/**
...@@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* (...@@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* (
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});
8890
apps/server/src/environment/Layers/ServerEnvironment.test.ts created+124
...@@ -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 created+94
...@@ -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 created+13
...@@ -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) => {...@@ -854,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
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 );
859859
860 it.effect(860 it.effect(
...@@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
962 ),962 ),
963 ).toBe(false);963 ).toBe(false);
964 }),964 }),
965 12_000,965 20_000,
966 );966 );
967967
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) => {...@@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1685 false,1685 false,
1686 );1686 );
1687 }),1687 }),
1688 12_000,1688 20_000,
1689 );1689 );
16901690
1691 it.effect(1691 it.effect(
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2
...@@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";...@@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
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", () => {...@@ -256,6 +257,7 @@ describe("CheckpointReactor", () => {
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 );
261263
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
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() {...@@ -45,6 +46,7 @@ async function createOrchestrationSystem() {
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", () => {...@@ -623,6 +625,7 @@ describe("OrchestrationEngine", () => {
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", () => {...@@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => {
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", () => {...@@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => {
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
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
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(...@@ -1846,6 +1847,7 @@ const engineLayer = it.layer(
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";...@@ -4,6 +4,7 @@ import { Effect, Layer } from "effect";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
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);...@@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
15const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);16const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
1617
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);
2024
21projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {25projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
...@@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
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...@@ -38,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh
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...@@ -163,6 +164,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
163164
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;
166169
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* () {...@@ -436,269 +439,283 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
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 ),
524507 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* () {...@@ -732,19 +749,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
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 );
750772
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+2
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
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", () => {...@@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => {
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
...@@ -29,6 +29,7 @@ import {...@@ -29,6 +29,7 @@ import {
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", () => {...@@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => {
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 created+193
...@@ -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 created+147
...@@ -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 created+12
...@@ -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";...@@ -5,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
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 {...@@ -83,6 +85,14 @@ import {
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 = {...@@ -93,6 +103,18 @@ const defaultModelSelection = {
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};
96118
97const makeDefaultOrchestrationReadModel = () => {119const makeDefaultOrchestrationReadModel = () => {
98 const now = new Date().toISOString();120 const now = new Date().toISOString();
...@@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: {...@@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: {
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?: {...@@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: {
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) => {...@@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
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) => {...@@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
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 });
10831121
1084 yield* buildAppUnderTest({1122 yield* buildAppUnderTest({
...@@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1996 }).pipe(Effect.provide(NodeHttpServer.layerTest)),2034 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1997 );2035 );
19982036
2037 it.effect("enriches replayed project events with repository identity metadata", () =>
2038 Effect.gen(function* () {
2039 const repositoryIdentity = {
2040 canonicalKey: "github.com/t3tools/t3code",
2041 locator: {
2042 source: "git-remote" as const,
2043 remoteName: "origin",
2044 remoteUrl: "git@github.com:T3Tools/t3code.git",
2045 },
2046 displayName: "T3Tools/t3code",
2047 provider: "github",
2048 owner: "T3Tools",
2049 name: "t3code",
2050 };
2051
2052 yield* buildAppUnderTest({
2053 layers: {
2054 orchestrationEngine: {
2055 readEvents: (_fromSequenceExclusive) =>
2056 Stream.make({
2057 sequence: 1,
2058 eventId: EventId.makeUnsafe("event-1"),
2059 aggregateKind: "project",
2060 aggregateId: defaultProjectId,
2061 occurredAt: "2026-04-05T00:00:00.000Z",
2062 commandId: null,
2063 causationEventId: null,
2064 correlationId: null,
2065 metadata: {},
2066 type: "project.created",
2067 payload: {
2068 projectId: defaultProjectId,
2069 title: "Default Project",
2070 workspaceRoot: "/tmp/default-project",
2071 defaultModelSelection,
2072 scripts: [],
2073 createdAt: "2026-04-05T00:00:00.000Z",
2074 updatedAt: "2026-04-05T00:00:00.000Z",
2075 },
2076 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>),
2077 },
2078 repositoryIdentityResolver: {
2079 resolve: () => Effect.succeed(repositoryIdentity),
2080 },
2081 },
2082 });
2083
2084 const wsUrl = yield* getWsServerUrl("/ws");
2085 const replayResult = yield* Effect.scoped(
2086 withWsRpcClient(wsUrl, (client) =>
2087 client[ORCHESTRATION_WS_METHODS.replayEvents]({
2088 fromSequenceExclusive: 0,
2089 }),
2090 ),
2091 );
2092
2093 const replayedEvent = replayResult[0];
2094 assert.equal(replayedEvent?.type, "project.created");
2095 assert.deepEqual(
2096 replayedEvent && replayedEvent.type === "project.created"
2097 ? replayedEvent.payload.repositoryIdentity
2098 : null,
2099 repositoryIdentity,
2100 );
2101 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2102 );
2103
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) => {...@@ -2498,6 +2603,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2498 }).pipe(Effect.provide(NodeHttpServer.layerTest)),2603 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2499 );2604 );
25002605
2606 it.effect("enriches replayed project events only once before streaming them to subscribers", () =>
2607 Effect.gen(function* () {
2608 let resolveCalls = 0;
2609 const repositoryIdentity = {
2610 canonicalKey: "github.com/t3tools/t3code",
2611 locator: {
2612 source: "git-remote" as const,
2613 remoteName: "origin",
2614 remoteUrl: "git@github.com:t3tools/t3code.git",
2615 },
2616 displayName: "t3tools/t3code",
2617 provider: "github" as const,
2618 owner: "t3tools",
2619 name: "t3code",
2620 };
2621
2622 yield* buildAppUnderTest({
2623 layers: {
2624 orchestrationEngine: {
2625 getReadModel: () =>
2626 Effect.succeed({
2627 ...makeDefaultOrchestrationReadModel(),
2628 snapshotSequence: 0,
2629 }),
2630 readEvents: () =>
2631 Stream.make({
2632 sequence: 1,
2633 eventId: EventId.makeUnsafe("event-1"),
2634 aggregateKind: "project",
2635 aggregateId: defaultProjectId,
2636 occurredAt: "2026-04-06T00:00:00.000Z",
2637 commandId: null,
2638 causationEventId: null,
2639 correlationId: null,
2640 metadata: {},
2641 type: "project.meta-updated",
2642 payload: {
2643 projectId: defaultProjectId,
2644 title: "Replayed Project",
2645 updatedAt: "2026-04-06T00:00:00.000Z",
2646 },
2647 } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>),
2648 streamDomainEvents: Stream.empty,
2649 },
2650 repositoryIdentityResolver: {
2651 resolve: () => {
2652 resolveCalls += 1;
2653 return Effect.succeed(repositoryIdentity);
2654 },
2655 },
2656 },
2657 });
2658
2659 const wsUrl = yield* getWsServerUrl("/ws");
2660 const events = yield* Effect.scoped(
2661 withWsRpcClient(wsUrl, (client) =>
2662 client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
2663 Stream.take(1),
2664 Stream.runCollect,
2665 ),
2666 ),
2667 );
2668
2669 const event = Array.from(events)[0];
2670 assert.equal(resolveCalls, 1);
2671 assert.equal(event?.type, "project.meta-updated");
2672 assert.deepEqual(
2673 event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null,
2674 repositoryIdentity,
2675 );
2676 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2677 );
2678
2679 it.effect("enriches subscribed project meta updates with repository identity metadata", () =>
2680 Effect.gen(function* () {
2681 const repositoryIdentity = {
2682 canonicalKey: "github.com/t3tools/t3code",
2683 locator: {
2684 source: "git-remote" as const,
2685 remoteName: "upstream",
2686 remoteUrl: "git@github.com:T3Tools/t3code.git",
2687 },
2688 displayName: "T3Tools/t3code",
2689 provider: "github",
2690 owner: "T3Tools",
2691 name: "t3code",
2692 };
2693
2694 yield* buildAppUnderTest({
2695 layers: {
2696 orchestrationEngine: {
2697 getReadModel: () =>
2698 Effect.succeed({
2699 ...makeDefaultOrchestrationReadModel(),
2700 snapshotSequence: 0,
2701 }),
2702 streamDomainEvents: Stream.make({
2703 sequence: 1,
2704 eventId: EventId.makeUnsafe("event-1"),
2705 aggregateKind: "project",
2706 aggregateId: defaultProjectId,
2707 occurredAt: "2026-04-05T00:00:00.000Z",
2708 commandId: null,
2709 causationEventId: null,
2710 correlationId: null,
2711 metadata: {},
2712 type: "project.meta-updated",
2713 payload: {
2714 projectId: defaultProjectId,
2715 title: "Renamed Project",
2716 updatedAt: "2026-04-05T00:00:00.000Z",
2717 },
2718 } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>),
2719 },
2720 repositoryIdentityResolver: {
2721 resolve: () => Effect.succeed(repositoryIdentity),
2722 },
2723 },
2724 });
2725
2726 const wsUrl = yield* getWsServerUrl("/ws");
2727 const events = yield* Effect.scoped(
2728 withWsRpcClient(wsUrl, (client) =>
2729 client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
2730 Stream.take(1),
2731 Stream.runCollect,
2732 ),
2733 ),
2734 );
2735
2736 const event = Array.from(events)[0];
2737 assert.equal(event?.type, "project.meta-updated");
2738 assert.deepEqual(
2739 event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null,
2740 repositoryIdentity,
2741 );
2742 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2743 );
2744
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
...@@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"...@@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"
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";
5254
53const PtyAdapterLive = Layer.unwrap(55const PtyAdapterLive = Layer.unwrap(
54 Effect.gen(function* () {56 Effect.gen(function* () {
...@@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe(...@@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe(
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),
202206
203 // Misc.207 // Misc.
204 Layer.provideMerge(AnalyticsServiceLayerLive),208 Layer.provideMerge(AnalyticsServiceLayerLive),
apps/server/src/serverLifecycleEvents.test.ts+10
...@@ -1,3 +1,4 @@...@@ -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(...@@ -9,12 +10,20 @@ it.effect(
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 };
1220
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(...@@ -29,6 +38,7 @@ it.effect(
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...@@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap
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";
3132
32const isWildcardHost = (host: string | undefined): boolean =>33const isWildcardHost = (host: string | undefined): boolean =>
...@@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () {...@@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
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;
265267
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* () {...@@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
308310
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* () {...@@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
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* () {...@@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
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 );
360370
apps/server/src/ws.ts+55-1
...@@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries";...@@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries";
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";
5052
51const WsRpcLayer = WsRpcGroup.toLayer(53const WsRpcLayer = WsRpcGroup.toLayer(
52 Effect.gen(function* () {54 Effect.gen(function* () {
...@@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer(...@@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer(
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()}`);
7276
...@@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer(...@@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer(
113 });117 });
114 };118 };
115119
120 const enrichProjectEvent = (
121 event: OrchestrationEvent,
122 ): Effect.Effect<OrchestrationEvent, never, never> => {
123 switch (event.type) {
124 case "project.created":
125 return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe(
126 Effect.map((repositoryIdentity) => ({
127 ...event,
128 payload: {
129 ...event.payload,
130 repositoryIdentity,
131 },
132 })),
133 );
134 case "project.meta-updated":
135 return Effect.gen(function* () {
136 const workspaceRoot =
137 event.payload.workspaceRoot ??
138 (yield* orchestrationEngine.getReadModel()).projects.find(
139 (project) => project.id === event.payload.projectId,
140 )?.workspaceRoot ??
141 null;
142 if (workspaceRoot === null) {
143 return event;
144 }
145
146 const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot);
147 return {
148 ...event,
149 payload: {
150 ...event.payload,
151 repositoryIdentity,
152 },
153 } satisfies OrchestrationEvent;
154 });
155 default:
156 return Effect.succeed(event);
157 }
158 };
159
160 const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) =>
161 Effect.forEach(events, enrichProjectEvent, { concurrency: 4 });
162
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(...@@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer(
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;
332380
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(...@@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer(
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(...@@ -455,10 +505,14 @@ const WsRpcLayer = WsRpcGroup.toLayer(
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
...@@ -12,6 +12,7 @@...@@ -12,6 +12,7 @@
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
...@@ -23,6 +23,7 @@...@@ -23,6 +23,7 @@
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 @@...@@ -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";
45
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 = [...@@ -20,7 +22,9 @@ const envModeItems = [
20] as const;22] as const;
2123
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 {...@@ -28,21 +32,34 @@ interface BranchToolbarProps {
28}32}
2933
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({...@@ -56,8 +73,8 @@ export default function BranchToolbar({
5673
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({...@@ -88,20 +105,24 @@ export default function BranchToolbar({
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({...@@ -156,6 +177,7 @@ export default function BranchToolbar({
156 )}177 )}
157178
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 @@...@@ -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 {...@@ -16,7 +16,7 @@ import {
1616
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 {...@@ -39,6 +39,7 @@ import {
39import { toastManager } from "./ui/toast";39import { toastManager } from "./ui/toast";
4040
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: {...@@ -70,6 +71,7 @@ function getBranchTriggerLabel(input: {
70}71}
7172
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({...@@ -85,16 +87,16 @@ export function BranchToolbarBranchSelector({
85 const [branchQuery, setBranchQuery] = useState("");87 const [branchQuery, setBranchQuery] = useState("");
86 const deferredBranchQuery = useDeferredValue(branchQuery);88 const deferredBranchQuery = useDeferredValue(branchQuery);
8789
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();
9193
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]);
98100
99 const {101 const {
100 data: branchesSearchData,102 data: branchesSearchData,
...@@ -104,6 +106,7 @@ export function BranchToolbarBranchSelector({...@@ -104,6 +106,7 @@ export function BranchToolbarBranchSelector({
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({...@@ -184,13 +187,13 @@ export function BranchToolbarBranchSelector({
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 };
191194
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;
195198
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({...@@ -248,7 +251,7 @@ export function BranchToolbarBranchSelector({
248251
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;
253256
254 setIsBranchMenuOpen(false);257 setIsBranchMenuOpen(false);
...@@ -302,10 +305,10 @@ export function BranchToolbarBranchSelector({...@@ -302,10 +305,10 @@ export function BranchToolbarBranchSelector({
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 );
310313
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";...@@ -22,7 +22,7 @@ import { fnv1a32 } from "../lib/diffRendering";
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";
2626
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) {...@@ -253,7 +253,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
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";...@@ -4,6 +4,7 @@ import "../index.css";
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 {...@@ -12,11 +13,16 @@ import {
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";...@@ -24,19 +30,20 @@ import { page } from "vitest/browser";
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";
2632
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", () => ({...@@ -49,8 +56,13 @@ vi.mock("../lib/gitStatusState", () => ({
49}));56}));
5057
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 {...@@ -129,6 +141,13 @@ function isoAt(offsetSeconds: number): string {
129141
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 {...@@ -314,6 +333,13 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
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...@@ -396,6 +422,33 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest
396 };422 };
397}423}
398424
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> {...@@ -419,25 +472,72 @@ async function waitForWsClient(): Promise<void> {
419 );472 );
420}473}
421474
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}
431518
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(...@@ -468,9 +568,12 @@ function withProjectScripts(
468568
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 {...@@ -479,8 +582,8 @@ function setDraftThreadWithoutWorktree(): void {
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 {...@@ -542,51 +645,12 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
542 };645 };
543}646}
544647
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 });
552653
553 const questions =
554 options?.questions ??
555 ([
556 {
557 id: "scope",
558 header: "Scope",
559 question: "What should this change cover?",
560 options: [
561 {
562 label: "Tight",
563 description: "Touch only the footer layout logic.",
564 },
565 {
566 label: "Broad",
567 description: "Also adjust the related composer controls.",
568 },
569 ],
570 multiSelect: false,
571 },
572 {
573 id: "risk",
574 header: "Risk",
575 question: "How aggressive should the imaginary plan be?",
576 options: [
577 {
578 label: "Conservative",
579 description: "Favor reliability and low-risk changes.",
580 },
581 {
582 label: "Balanced",
583 description: "Mix quick wins with one structural improvement.",
584 },
585 ],
586 multiSelect: false,
587 },
588 ] satisfies ReadonlyArray<UserInputQuestion>);
589
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?: {...@@ -601,7 +665,38 @@ function createSnapshotWithPendingUserInput(options?: {
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: {...@@ -1057,7 +1152,7 @@ async function mountChatView(options: {
10571152
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 );
10631158
...@@ -1162,42 +1257,32 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1162,42 +1257,32 @@ describe("ChatView timeline estimator parity (full app)", () => {
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)", () => {...@@ -1241,6 +1326,35 @@ describe("ChatView timeline estimator parity (full app)", () => {
1241 },1326 },
1242 );1327 );
12431328
1329 it("re-expands the bootstrap project using its scoped key", async () => {
1330 useUiStateStore.setState({
1331 projectExpandedById: {
1332 [PROJECT_KEY]: false,
1333 },
1334 projectOrder: [PROJECT_KEY],
1335 threadLastVisitedAtById: {},
1336 });
1337
1338 const mounted = await mountChatView({
1339 viewport: DEFAULT_VIEWPORT,
1340 snapshot: createSnapshotForTargetUser({
1341 targetMessageId: "msg-user-bootstrap-project-expand" as MessageId,
1342 targetText: "bootstrap project expand",
1343 }),
1344 });
1345
1346 try {
1347 await vi.waitFor(
1348 () => {
1349 expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true);
1350 },
1351 { timeout: 8_000, interval: 16 },
1352 );
1353 } finally {
1354 await mounted.cleanup();
1355 }
1356 });
1357
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)", () => {...@@ -1438,8 +1552,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
1438 }1552 }
14391553
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)", () => {...@@ -1449,8 +1563,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
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)", () => {...@@ -1696,9 +1810,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
16961810
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)", () => {...@@ -1707,8 +1824,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
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 });
17141831
...@@ -1772,9 +1889,12 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1772,9 +1889,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
17721889
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)", () => {...@@ -1783,8 +1903,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
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 });
17901910
...@@ -1835,9 +1955,12 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1835,9 +1955,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
18351955
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)", () => {...@@ -1846,8 +1969,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
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 });
18531976
...@@ -1957,12 +2080,15 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1957,12 +2080,15 @@ describe("ChatView timeline estimator parity (full app)", () => {
19572080
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)", () => {...@@ -1971,8 +2097,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
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 });
19782104
...@@ -1998,7 +2124,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1998,7 +2124,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1998 });2124 });
19992125
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();
20032129
2004 const sendButton = await waitForSendButton();2130 const sendButton = await waitForSendButton();
...@@ -2057,12 +2183,15 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2057,12 +2183,15 @@ describe("ChatView timeline estimator parity (full app)", () => {
20572183
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)", () => {...@@ -2071,8 +2200,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
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 });
20782207
...@@ -2101,7 +2230,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2101,7 +2230,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
2101 });2230 });
21022231
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();
21062235
2107 const sendButton = await waitForSendButton();2236 const sendButton = await waitForSendButton();
...@@ -2193,7 +2322,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2193,7 +2322,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
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)", () => {...@@ -2220,21 +2349,21 @@ describe("ChatView timeline estimator parity (full app)", () => {
2220 );2349 );
22212350
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");
22272356
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 );
22352364
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)", () => {...@@ -2246,7 +2375,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
22462375
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)", () => {...@@ -2261,7 +2390,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
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)", () => {...@@ -2297,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
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)", () => {...@@ -2308,7 +2437,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
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`);
23122441
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)", () => {...@@ -2448,7 +2577,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
2448 }2577 }
2449 });2578 });
24502579
2451 it("keeps the new thread selected after clicking the new-thread button", async () => {2580 it("canonicalizes promoted draft threads to the server thread route", async () => {
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)", () => {...@@ -2470,21 +2599,34 @@ describe("ChatView timeline estimator parity (full app)", () => {
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);
24742604
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();
24772607
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 );
24822624
2483 // The route should still be on the new thread — not redirected away.2625 // The route should switch to the canonical server thread path.
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 );
24892631
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)", () => {...@@ -2497,6 +2639,48 @@ describe("ChatView timeline estimator parity (full app)", () => {
2497 }2639 }
2498 });2640 });
24992641
2642 it("canonicalizes stale promoted draft routes to the server thread route", async () => {
2643 const mounted = await mountChatView({
2644 viewport: DEFAULT_VIEWPORT,
2645 snapshot: createSnapshotForTargetUser({
2646 targetMessageId: "msg-user-draft-hydration-race-test" as MessageId,
2647 targetText: "draft hydration race test",
2648 }),
2649 });
2650
2651 try {
2652 const newThreadButton = page.getByTestId("new-thread-button");
2653 await expect.element(newThreadButton).toBeInTheDocument();
2654
2655 await newThreadButton.click();
2656
2657 const newThreadPath = await waitForURL(
2658 mounted.router,
2659 (path) => UUID_ROUTE_RE.test(path),
2660 "Route should have changed to a new draft thread UUID.",
2661 );
2662 const newDraftId = draftIdFromPath(newThreadPath);
2663 const newThreadId = draftThreadIdFor(newDraftId);
2664
2665 await promoteDraftThreadViaDomainEvent(newThreadId);
2666
2667 await mounted.router.navigate({
2668 to: "/draft/$draftId",
2669 params: { draftId: newDraftId },
2670 });
2671
2672 await waitForURL(
2673 mounted.router,
2674 (path) => path === serverThreadPath(newThreadId),
2675 "Stale promoted draft routes should canonicalize to the server thread path.",
2676 );
2677
2678 await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument();
2679 } finally {
2680 await mounted.cleanup();
2681 }
2682 });
2683
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)", () => {...@@ -2531,9 +2715,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
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);
25352719
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)", () => {...@@ -2584,9 +2768,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
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);
25882772
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)", () => {...@@ -2624,9 +2808,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
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);
26282812
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)", () => {...@@ -2666,9 +2850,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
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);
26702854
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)", () => {...@@ -2681,7 +2865,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
2681 activeProvider: "codex",2865 activeProvider: "codex",
2682 });2866 });
26832867
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)", () => {...@@ -2697,7 +2881,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
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)", () => {...@@ -2804,7 +2988,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
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);
28082993
2809 await promoteDraftThreadViaDomainEvent(promotedThreadId);2994 await promoteDraftThreadViaDomainEvent(promotedThreadId);
28102995
...@@ -2925,70 +3110,10 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2925,70 +3110,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
2925 }3110 }
2926 });3111 });
29273112
2928 it("does not trigger numeric option shortcuts while the composer is focused", async () => {3113 it("submits pending user input after the final option selection resolves the draft answers", async () => {
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)", () => {...@@ -3000,37 +3125,11 @@ describe("ChatView timeline estimator parity (full app)", () => {
3000 });3125 });
30013126
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();
30303130
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();
30343133
3035 await vi.waitFor(3134 await vi.waitFor(
3036 () => {3135 () => {
...@@ -3042,6 +3141,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3042,6 +3141,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
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)", () => {...@@ -3049,9 +3149,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
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 @@...@@ -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";
56
6import {7import {
...@@ -10,9 +11,12 @@ import {...@@ -10,9 +11,12 @@ import {
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";
1517
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", () => {...@@ -170,6 +174,36 @@ describe("reconcileMountedTerminalThreadIds", () => {
170 });174 });
171});175});
172176
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?: {...@@ -181,6 +215,7 @@ const makeThread = (input?: {
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?: {...@@ -208,11 +243,12 @@ const makeThread = (input?: {
208243
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>>)...@@ -233,6 +269,7 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>)
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>>)...@@ -301,6 +338,12 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>)
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}
306349
...@@ -326,14 +369,16 @@ describe("waitForStartedServerThread", () => {...@@ -326,14 +369,16 @@ describe("waitForStartedServerThread", () => {
326 }),369 }),
327 ]);370 ]);
328371
329 await expect(waitForStartedServerThread(threadId)).resolves.toBe(true);372 await expect(
373 waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)),
374 ).resolves.toBe(true);
330 });375 });
331376
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 })]);
335380
336 const promise = waitForStartedServerThread(threadId, 500);381 const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500);
337382
338 setStoreThreads([383 setStoreThreads([
339 makeThread({384 makeThread({
...@@ -376,7 +421,9 @@ describe("waitForStartedServerThread", () => {...@@ -376,7 +421,9 @@ describe("waitForStartedServerThread", () => {
376 return originalSubscribe(listener);421 return originalSubscribe(listener);
377 });422 });
378423
379 await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true);424 await expect(
425 waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500),
426 ).resolves.toBe(true);
380 });427 });
381428
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", () => {...@@ -384,7 +431,7 @@ describe("waitForStartedServerThread", () => {
384431
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);
388435
389 await vi.advanceTimersByTimeAsync(500);436 await vi.advanceTimersByTimeAsync(500);
390437
...@@ -414,6 +461,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {...@@ -414,6 +461,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
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", () => {...@@ -450,6 +498,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
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", () => {...@@ -495,6 +544,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
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 @@...@@ -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(...@@ -24,6 +31,7 @@ export function buildLocalDraftThread(
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(...@@ -44,13 +52,32 @@ export function buildLocalDraftThread(
44 };52 };
45}53}
4654
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 {...@@ -199,10 +226,10 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean {
199}226}
200227
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();
207234
208 if (threadHasStarted(thread)) {235 if (threadHasStarted(thread)) {
...@@ -225,7 +252,7 @@ export async function waitForStartedServerThread(...@@ -225,7 +252,7 @@ export async function waitForStartedServerThread(
225 };252 };
226253
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 {...@@ -2,6 +2,7 @@ import {
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 {...@@ -12,6 +13,7 @@ import {
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 {...@@ -20,6 +22,12 @@ import {
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...@@ -27,9 +35,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
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 {...@@ -64,8 +75,8 @@ import {
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 {...@@ -114,8 +125,7 @@ import {
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 {...@@ -124,6 +134,8 @@ import {
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 {...@@ -131,6 +143,7 @@ import {
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 {...@@ -187,6 +200,7 @@ import {
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...@@ -203,6 +217,7 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
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...@@ -210,26 +225,115 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw
210type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;225type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
211226
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 }
216275
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 }
223326
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 }) ?? [];
229330
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}
235339
...@@ -278,9 +382,19 @@ const terminalContextIdListsEqual = (...@@ -278,9 +382,19 @@ const terminalContextIdListsEqual = (
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]);
280384
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 };
284398
285interface TerminalLaunchContext {399interface TerminalLaunchContext {
286 threadId: ThreadId;400 threadId: ThreadId;
...@@ -358,6 +472,7 @@ function useLocalDispatchState(input: {...@@ -358,6 +472,7 @@ function useLocalDispatchState(input: {
358}472}
359473
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 {...@@ -369,6 +484,7 @@ interface PersistentThreadTerminalDrawerProps {
369}484}
370485
371function PersistentThreadTerminalDrawer({486function PersistentThreadTerminalDrawer({
487 threadRef,
372 threadId,488 threadId,
373 visible,489 visible,
374 launchContext,490 launchContext,
...@@ -378,19 +494,16 @@ function PersistentThreadTerminalDrawer({...@@ -378,19 +494,16 @@ function PersistentThreadTerminalDrawer({
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({...@@ -436,32 +549,32 @@ function PersistentThreadTerminalDrawer({
436549
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 );
443556
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]);
448561
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]);
453566
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 );
461574
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({...@@ -482,10 +595,10 @@ function PersistentThreadTerminalDrawer({
482 void fallbackExitWrite();595 void fallbackExitWrite();
483 }596 }
484597
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 );
490603
491 const handleAddTerminalContext = useCallback(604 const handleAddTerminalContext = useCallback(
...@@ -505,6 +618,7 @@ function PersistentThreadTerminalDrawer({...@@ -505,6 +618,7 @@ function PersistentThreadTerminalDrawer({
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({...@@ -530,12 +644,27 @@ function PersistentThreadTerminalDrawer({
530 );644 );
531}645}
532646
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) {...@@ -548,7 +677,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -591,16 +720,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -610,8 +743,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -689,66 +822,73 @@ export default function ChatView({ threadId }: ChatViewProps) {
689 setMessagesScrollElement(element);822 setMessagesScrollElement(element);
690 }, []);823 }, []);
691824
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[]>([]);
722862
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) {...@@ -761,7 +901,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -770,13 +910,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
770 ),910 ),
771 );911 );
772 },912 },
773 [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId],913 [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt],
774 );914 );
775915
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) {...@@ -792,20 +938,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -825,12 +976,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -838,10 +989,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 );
8461000
847 const openPullRequestDialog = useCallback(1001 const openPullRequestDialog = useCallback(
...@@ -867,50 +1021,71 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -867,50 +1021,71 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 }
8821046
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 }
8891063
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 );
9161091
...@@ -934,12 +1109,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -934,12 +1109,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
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;
9361111
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 ]);
9451121
...@@ -959,7 +1135,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -959,7 +1135,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1358,7 +1534,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1394,6 +1570,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1531,16 +1708,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
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]);
15441727
1545 const envLocked = Boolean(1728 const envLocked = Boolean(
1546 activeThread &&1729 activeThread &&
...@@ -1561,21 +1744,27 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1561,21 +1744,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 );
15801769
1581 const focusComposer = useCallback(() => {1770 const focusComposer = useCallback(() => {
...@@ -1606,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1606,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1630,30 +1819,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1676,10 +1865,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1692,7 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1719,10 +1916,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
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);
17281928
...@@ -1769,12 +1969,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1769,12 +1969,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1790,7 +1992,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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;
17951997
1796 await api.orchestration.dispatchCommand({1998 await api.orchestration.dispatchCommand({
...@@ -1806,10 +2008,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1806,10 +2008,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
1806 });2008 });
18072009
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) {...@@ -1913,9 +2119,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1923,18 +2129,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 );
19312137
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) {...@@ -1942,9 +2148,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -1980,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -2020,7 +2226,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2020 });2226 });
2021 }2227 }
2022 },2228 },
2023 [serverThread],2229 [environmentId, serverThread],
2024 );2230 );
20252231
2026 // Auto-scroll on new messages2232 // Auto-scroll on new messages
...@@ -2350,17 +2556,17 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2350,17 +2556,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
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]);
23542560
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) {...@@ -2391,7 +2597,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -2405,17 +2611,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 ]);
24202627
2421 const closeExpandedImage = useCallback(() => {2628 const closeExpandedImage = useCallback(() => {
...@@ -2476,7 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2476,7 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -2484,7 +2691,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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]);
24882695
2489 useEffect(() => {2696 useEffect(() => {
2490 if (!activeThreadId || !activeProjectCwd) {2697 if (!activeThreadId || !activeProjectCwd) {
...@@ -2502,12 +2709,20 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2502,12 +2709,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 ]);
25112726
2512 useEffect(() => {2727 useEffect(() => {
2513 if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) {2728 if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) {
...@@ -2521,11 +2736,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2521,11 +2736,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -2535,11 +2753,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 ]);
25432766
2544 useEffect(() => {2767 useEffect(() => {
2545 if (phase !== "running") return;2768 if (phase !== "running") return;
...@@ -2552,16 +2775,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2552,16 +2775,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
2552 }, [phase]);2775 }, [phase]);
25532776
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);
25582781
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) {...@@ -2570,8 +2793,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
2570 };2793 };
2571 }2794 }
25722795
2573 terminalOpenByThreadRef.current[activeThreadId] = current;2796 terminalOpenByThreadRef.current[activeThreadKey] = current;
2574 }, [activeThreadId, focusComposer, terminalState.terminalOpen]);2797 }, [activeThreadKey, focusComposer, terminalState.terminalOpen]);
25752798
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) {...@@ -2766,14 +2989,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
27662989
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;
27712995
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) {...@@ -2802,12 +3026,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 );
28073039
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) {...@@ -2830,7 +3062,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -2847,7 +3079,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -2880,10 +3112,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 }
28893118
...@@ -2950,7 +3179,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2950,7 +3179,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3087,7 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3087 };3316 };
30883317
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) {...@@ -3099,7 +3328,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
30993328
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;
31043333
3105 setRespondingRequestIds((existing) =>3334 setRespondingRequestIds((existing) =>
...@@ -3122,12 +3351,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3122,12 +3351,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 );
31273356
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;
31323361
3133 setRespondingUserInputRequestIds((existing) =>3362 setRespondingUserInputRequestIds((existing) =>
...@@ -3150,7 +3379,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3150,7 +3379,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 );
31553384
3156 const setActivePendingUserInputQuestionIndex = useCallback(3385 const setActivePendingUserInputQuestionIndex = useCallback(
...@@ -3166,31 +3395,38 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3166,31 +3395,38 @@ export default function ChatView({ threadId }: ChatViewProps) {
3166 [activePendingUserInput],3395 [activePendingUserInput],
3167 );3396 );
31683397
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 );
31953431
3196 const onChangeActivePendingUserInputCustomAnswer = useCallback(3432 const onChangeActivePendingUserInputCustomAnswer = useCallback(
...@@ -3257,7 +3493,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3257,7 +3493,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3312,7 +3548,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
33123548
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 );
33163555
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) {...@@ -3376,11 +3615,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
3376 setComposerDraftInteractionMode,3615 setComposerDraftInteractionMode,
3377 setThreadError,3616 setThreadError,
3378 selectedModel,3617 selectedModel,
3618 environmentId,
3379 ],3619 ],
3380 );3620 );
33813621
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) {...@@ -3452,17 +3692,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3494,6 +3737,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3494 selectedProvider,3737 selectedProvider,
3495 selectedProviderModels,3738 selectedProviderModels,
3496 selectedModel,3739 selectedModel,
3740 environmentId,
3497 ]);3741 ]);
34983742
3499 const onProviderModelSelect = useCallback(3743 const onProviderModelSelect = useCallback(
...@@ -3514,7 +3758,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3514,7 +3758,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3546,7 +3793,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3555,7 +3803,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3565,11 +3814,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
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 );
35743823
3575 const applyPromptReplacement = useCallback(3824 const applyPromptReplacement = useCallback(
...@@ -3766,7 +4015,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3766,7 +4015,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3781,8 +4030,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
3781 composerTerminalContexts,4030 composerTerminalContexts,
3782 onChangeActivePendingUserInputCustomAnswer,4031 onChangeActivePendingUserInputCustomAnswer,
3783 setPrompt,4032 setPrompt,
4033 composerDraftTarget,
3784 setComposerDraftTerminalContexts,4034 setComposerDraftTerminalContexts,
3785 threadId,
3786 ],4035 ],
3787 );4036 );
37884037
...@@ -3835,9 +4084,15 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3835,9 +4084,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3846,7 +4101,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3892,6 +4147,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -3959,6 +4215,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -4027,7 +4284,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -4370,7 +4627,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
43704627
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) {...@@ -4383,6 +4642,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -4402,6 +4662,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {...@@ -4418,21 +4679,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
4418 </div>4679 </div>
4419 {/* end horizontal flex container */}4680 {/* end horizontal flex container */}
44204681
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 })}
44364704
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";...@@ -2,7 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs";
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";...@@ -22,15 +23,16 @@ import { openInPreferredEditor } from "../editorPreferences";
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) {...@@ -175,22 +177,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
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) {...@@ -263,6 +273,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
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) {...@@ -322,7 +333,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
322333
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) {...@@ -335,8 +346,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
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) {...@@ -346,8 +357,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
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 @@...@@ -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";...@@ -5,10 +6,24 @@ import { render } from "vitest-browser-react";
56
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";
1012
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 {...@@ -18,9 +33,10 @@ const {
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 {...@@ -28,12 +44,8 @@ const {
28 toastUpdateSpy: vi.fn(),44 toastUpdateSpy: vi.fn(),
29}));45}));
3046
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 () => {...@@ -56,27 +68,7 @@ vi.mock("@tanstack/react-query", async () => {
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", () => ({...@@ -123,27 +115,44 @@ vi.mock("~/lib/gitStatusState", () => ({
123 })),115 })),
124}));116}));
125117
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}));
139121
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() {...@@ -168,7 +177,10 @@ function Harness() {
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", () => {...@@ -177,6 +189,7 @@ describe("GitActionsControl thread-scoped progress toast", () => {
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 });
182195
...@@ -231,6 +244,9 @@ describe("GitActionsControl thread-scoped progress toast", () => {...@@ -231,6 +244,9 @@ describe("GitActionsControl thread-scoped progress toast", () => {
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", () => {...@@ -248,9 +264,15 @@ describe("GitActionsControl thread-scoped progress toast", () => {
248264
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 );
254276
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", () => {...@@ -264,11 +286,15 @@ describe("GitActionsControl thread-scoped progress toast", () => {
264286
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 @@...@@ -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 {...@@ -49,12 +49,14 @@ import {
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";
5456
55interface GitActionsControlProps {57interface GitActionsControlProps {
56 gitCwd: string | null;58 gitCwd: string | null;
57 activeThreadId: ThreadId | null;59 activeThreadRef: ScopedThreadRef | null;
58}60}
5961
60interface PendingDefaultBranchAction {62interface PendingDefaultBranchAction {
...@@ -206,14 +208,18 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {...@@ -206,14 +208,18 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
206 return <InfoIcon className={iconClassName} />;208 return <InfoIcon className={iconClassName} />;
207}209}
208210
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...@@ -246,7 +252,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
246 }252 }
247253
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...@@ -261,7 +267,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
261267
262 setThreadBranch(activeThreadId, branch, worktreePath);268 setThreadBranch(activeThreadId, branch, worktreePath);
263 },269 },
264 [activeServerThread, activeThreadId, setThreadBranch],270 [activeEnvironmentId, activeServerThread, activeThreadId, setThreadBranch],
265 );271 );
266272
267 const syncThreadBranchAfterGitAction = useCallback(273 const syncThreadBranchAfterGitAction = useCallback(
...@@ -276,7 +282,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -276,7 +282,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
276 [persistThreadBranchSync],282 [persistThreadBranchSync],
277 );283 );
278284
279 const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd);285 const { data: gitStatus = null, error: gitStatusError } = useGitStatus({
286 environmentId: activeEnvironmentId,
287 cwd: gitCwd,
288 });
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...@@ -287,19 +296,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
287 const allSelected = excludedFiles.size === 0;296 const allSelected = excludedFiles.size === 0;
288 const noneSelected = selectedFiles.length === 0;297 const noneSelected = selectedFiles.length === 0;
289298
290 const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));299 const initMutation = useMutation(
300 gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),
301 );
291302
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 );
299313
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;
304321
305 useEffect(() => {322 useEffect(() => {
...@@ -372,7 +389,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -372,7 +389,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
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...@@ -391,10 +410,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
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]);
395414
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...@@ -412,7 +431,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
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...@@ -601,7 +620,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
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...@@ -760,7 +779,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
760779
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...@@ -836,7 +855,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
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";...@@ -2,6 +2,7 @@ import "../index.css";
22
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 }...@@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
18import { render } from "vitest-browser-react";19import { render } from "vitest-browser-react";
1920
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", () => ({...@@ -34,6 +35,7 @@ vi.mock("../lib/gitStatusState", () => ({
3435
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";
3840
39interface TestFixture {41interface TestFixture {
...@@ -49,6 +51,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/);...@@ -49,6 +51,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/);
4951
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 {...@@ -155,6 +164,13 @@ function buildFixture(): TestFixture {
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> }> {...@@ -286,7 +302,9 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {
286 host.style.overflow = "hidden";302 host.style.overflow = "hidden";
287 document.body.append(host);303 document.body.append(host);
288304
289 const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] }));305 const router = getRouter(
306 createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }),
307 );
290308
291 const screen = await render(309 const screen = await render(
292 <AppAtomRegistryProvider>310 <AppAtomRegistryProvider>
...@@ -347,32 +365,17 @@ describe("Keybindings update toast", () => {...@@ -347,32 +365,17 @@ describe("Keybindings update toast", () => {
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 });
378381
apps/web/src/components/PlanSidebar.tsx+6-3
...@@ -1,4 +1,5 @@...@@ -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 {...@@ -24,7 +25,7 @@ import {
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";
3031
...@@ -53,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode {...@@ -53,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode {
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 {...@@ -62,6 +64,7 @@ interface PlanSidebarProps {
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({...@@ -87,7 +90,7 @@ const PlanSidebar = memo(function PlanSidebar({
87 }, [planMarkdown]);90 }, [planMarkdown]);
8891
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({...@@ -115,7 +118,7 @@ const PlanSidebar = memo(function PlanSidebar({
115 () => setIsSavingToWorkspace(false),118 () => setIsSavingToWorkspace(false),
116 () => setIsSavingToWorkspace(false),119 () => setIsSavingToWorkspace(false),
117 );120 );
118 }, [planMarkdown, workspaceRoot]);121 }, [environmentId, planMarkdown, workspaceRoot]);
119122
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 @@...@@ -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";...@@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner";
2424
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 {...@@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps {
3334
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({...@@ -72,6 +74,7 @@ export function PullRequestThreadDialog({
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({...@@ -83,13 +86,14 @@ export function PullRequestThreadDialog({
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 );
9498
95 const liveResolvedPullRequest =99 const liveResolvedPullRequest =
apps/web/src/components/Sidebar.logic.test.ts+5-1
...@@ -20,7 +20,7 @@ import {...@@ -20,7 +20,7 @@ import {
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 {...@@ -28,6 +28,8 @@ import {
28 type Thread,28 type Thread,
29} from "../types";29} from "../types";
3030
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 {...@@ -625,6 +627,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
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 {...@@ -642,6 +645,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
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 {...@@ -12,20 +12,7 @@ import {
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";...@@ -45,11 +32,20 @@ import { CSS } from "@dnd-kit/utilities";
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";...@@ -58,7 +54,13 @@ import { isElectron } from "../env";
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 {...@@ -70,11 +72,16 @@ import {
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";
7678
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 {...@@ -109,8 +116,6 @@ import {
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 {...@@ -123,12 +128,14 @@ import {
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 = {...@@ -143,9 +150,58 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
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}
146202
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({...@@ -166,7 +222,7 @@ function ThreadStatusLabel({
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(...@@ -255,57 +311,81 @@ function resolveThreadPr(
255}311}
256312
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}
292349
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({
302386 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) {...@@ -318,32 +398,173 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
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} />, []);
327558
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) {...@@ -351,31 +572,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
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) {...@@ -386,9 +585,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
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) {...@@ -398,30 +595,15 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
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) {...@@ -441,34 +623,19 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
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) {...@@ -476,17 +643,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
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) {...@@ -502,14 +660,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
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) {...@@ -521,12 +673,12 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
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) {...@@ -545,248 +697,288 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
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}
549748
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,
565764 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" />, []);
570789
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}
637893
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>(
674917 (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();
788980
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() {...@@ -803,222 +995,495 @@ export default function Sidebar() {
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]);
8061082
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 );
8211191
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 );
8401203
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 }
8471216
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 };
8551221
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;
8621229
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 );
9081300
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 );
9121314
913 const canAddProject = newCwd.trim().length > 0 && !isAddingProject;1315 const handleThreadClick = useCallback(
1316 (
1317 event: React.MouseEvent,
1318 threadRef: ScopedThreadRef,
1319 orderedProjectThreadKeys: readonly string[],
1320 ) => {
1321 const isMac = isMacPlatform(navigator.platform);
1322 const isModClick = isMac ? event.metaKey : event.ctrlKey;
1323 const isShiftClick = event.shiftKey;
1324 const threadKey = scopedThreadKey(threadRef);
1325 const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size;
9141326
915 const handlePickFolder = async () => {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 }, []);
9461332
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 }, []);
9581338
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 );
9631350
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;
9681358
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 };
9781366
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 );
10151474
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() {...@@ -1027,7 +1492,7 @@ export default function Sidebar() {
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() {...@@ -1036,22 +1501,22 @@ export default function Sidebar() {
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() {...@@ -1060,50 +1525,19 @@ export default function Sidebar() {
1060 [],1525 [],
1061 );1526 );
10621527
1063 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
1064 threadId: ThreadId;
1065 }>({
1066 onCopy: (ctx) => {
1067 toastManager.add({
1068 type: "success",
1069 title: "Thread ID copied",
1070 description: ctx.threadId,
1071 });
1072 },
1073 onError: (error) => {
1074 toastManager.add({
1075 type: "error",
1076 title: "Failed to copy thread ID",
1077 description: error instanceof Error ? error.message : "An error occurred.",
1078 });
1079 },
1080 });
1081 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
1082 path: string;
1083 }>({
1084 onCopy: (ctx) => {
1085 toastManager.add({
1086 type: "success",
1087 title: "Path copied",
1088 description: ctx.path,
1089 });
1090 },
1091 onError: (error) => {
1092 toastManager.add({
1093 type: "error",
1094 title: "Failed to copy path",
1095 description: error instanceof Error ? error.message : "An error occurred.",
1096 });
1097 },
1098 });
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() {...@@ -1116,16 +1550,14 @@ export default function Sidebar() {
1116 );1550 );
11171551
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 }
11261558
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() {...@@ -1141,11 +1573,11 @@ export default function Sidebar() {
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() {...@@ -1156,198 +1588,903 @@ export default function Sidebar() {
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 );
11711603
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 ${
11791611 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}
11871619 >
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
11961628 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
1197 if (clicked !== "delete") return;1629 projectStatus.pulse ? "animate-pulse" : ""
11981630 }`}
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 }`}
12081640 />
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});
12251710
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});
12631718
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}
12771734
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;
12841739
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;
13062358
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 }
13162391
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();
13192394 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 );
13502440
2441 const handleAddProject = () => {
2442 void addProjectFromPath(newCwd);
2443 };
2444
2445 const canAddProject = newCwd.trim().length > 0 && !isAddingProject;
2446
2447 const handlePickFolder = async () => {
2448 const api = readLocalApi();
2449 if (!api || isPickingFolder) return;
2450 setIsPickingFolder(true);
2451 let pickedPath: string | null = null;
2452 try {
2453 pickedPath = await api.dialogs.pickFolder();
2454 } catch {
2455 // Ignore picker failures and leave the current thread selection unchanged.
2456 }
2457 if (pickedPath) {
2458 await addProjectFromPath(pickedPath);
2459 } else if (!shouldBrowseForProjectImmediately) {
2460 addProjectInputRef.current?.focus();
2461 }
2462 setIsPickingFolder(false);
2463 };
2464
2465 const handleStartAddProject = () => {
2466 setAddProjectError(null);
2467 if (shouldBrowseForProjectImmediately) {
2468 void handlePickFolder();
2469 return;
2470 }
2471 setAddingProject((prev) => !prev);
2472 };
2473
2474 const navigateToThread = useCallback(
2475 (threadRef: ScopedThreadRef) => {
2476 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
2477 clearSelection();
2478 }
2479 setSelectionAnchor(scopedThreadKey(threadRef));
2480 void navigate({
2481 to: "/$environmentId/$threadId",
2482 params: buildThreadRouteParams(threadRef),
2483 });
2484 },
2485 [clearSelection, navigate, setSelectionAnchor],
2486 );
2487
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() {...@@ -1371,10 +2508,10 @@ export default function Sidebar() {
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() {...@@ -1412,148 +2549,117 @@ export default function Sidebar() {
1412 animatedThreadListsRef.current.add(node);2549 animatedThreadListsRef.current.add(node);
1413 }, []);2550 }, []);
14142551
1415 const handleProjectTitlePointerDownCapture = useCallback(
1416 (event: PointerEvent<HTMLButtonElement>) => {
1417 suppressProjectClickForContextMenuRef.current = false;
1418 if (
1419 isContextMenuPointerDown({
1420 button: event.button,
1421 ctrlKey: event.ctrlKey,
1422 isMac: isMacPlatform(navigator.platform),
1423 })
1424 ) {
1425 // Keep context-menu gestures from arming the sortable drag sensor.
1426 event.stopPropagation();
1427 }
1428
1429 suppressProjectClickAfterDragRef.current = false;
1430 },
1431 [],
1432 );
1433
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 }
15252629
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;
15432642
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);
15572663
1558 if (event.defaultPrevented || event.repeat) {2664 if (event.defaultPrevented || event.repeat) {
1559 return;2665 return;
...@@ -1561,22 +2667,26 @@ export default function Sidebar() {...@@ -1561,22 +2667,26 @@ export default function Sidebar() {
15612667
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 }
15762686
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 }
15822692
...@@ -1585,26 +2695,45 @@ export default function Sidebar() {...@@ -1585,26 +2695,45 @@ export default function Sidebar() {
1585 return;2695 return;
1586 }2696 }
15872697
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 }
15922706
1593 event.preventDefault();2707 event.preventDefault();
1594 event.stopPropagation();2708 event.stopPropagation();
1595 navigateToThread(targetThreadId);2709 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
1596 };2710 };
15972711
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 };
16062732
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 };
16102739
...@@ -1618,301 +2747,21 @@ export default function Sidebar() {...@@ -1618,301 +2747,21 @@ export default function Sidebar() {
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 ]);
16302761
1631 function renderProjectItem(
1632 renderedProject: (typeof renderedProjects)[number],
1633 dragHandleProps: SortableProjectHandleProps | null,
1634 ) {
1635 const {
1636 hasHiddenThreads,
1637 hiddenThreadStatus,
1638 orderedProjectThreadIds,
1639 project,
1640 projectStatus,
1641 renderedThreadIds,
1642 showEmptyThreadState,
1643 shouldShowThreadPanel,
1644 isThreadListExpanded,
1645 } = renderedProject;
1646 return (
1647 <>
1648 <div className="group/project-header relative">
1649 <SidebarMenuButton
1650 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
1651 size="sm"
1652 className={`gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${
1653 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
1654 }`}
1655 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
1656 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
1657 onPointerDownCapture={handleProjectTitlePointerDownCapture}
1658 onClick={(event) => handleProjectTitleClick(event, project.id)}
1659 onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}
1660 onContextMenu={(event) => {
1661 event.preventDefault();
1662 suppressProjectClickForContextMenuRef.current = true;
1663 void handleProjectContextMenu(project.id, {
1664 x: event.clientX,
1665 y: event.clientY,
1666 });
1667 }}
1668 >
1669 {!project.expanded && projectStatus ? (
1670 <span
1671 aria-hidden="true"
1672 title={projectStatus.label}
1673 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
1674 >
1675 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
1676 <span
1677 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
1678 projectStatus.pulse ? "animate-pulse" : ""
1679 }`}
1680 />
1681 </span>
1682 <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
1683 </span>
1684 ) : (
1685 <ChevronRightIcon
1686 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1687 project.expanded ? "rotate-90" : ""
1688 }`}
1689 />
1690 )}
1691 <ProjectFavicon cwd={project.cwd} />
1692 {renamingProjectId === project.id ? (
1693 <input
1694 ref={(element) => {
1695 if (element && projectRenamingInputRef.current !== element) {
1696 projectRenamingInputRef.current = element;
1697 element.focus();
1698 element.select();
1699 }
1700 }}
1701 className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-xs font-medium text-foreground/90 outline-none"
1702 value={renamingProjectTitle}
1703 onChange={(event) => setRenamingProjectTitle(event.target.value)}
1704 onKeyDown={(event) => {
1705 event.stopPropagation();
1706 if (event.key === "Enter") {
1707 event.preventDefault();
1708 projectRenamingCommittedRef.current = true;
1709 void commitProjectRename(project.id, renamingProjectTitle, project.name);
1710 } else if (event.key === "Escape") {
1711 event.preventDefault();
1712 projectRenamingCommittedRef.current = true;
1713 cancelProjectRename();
1714 }
1715 }}
1716 onBlur={() => {
1717 if (!projectRenamingCommittedRef.current) {
1718 void commitProjectRename(project.id, renamingProjectTitle, project.name);
1719 }
1720 }}
1721 onClick={(event) => event.stopPropagation()}
1722 onPointerDown={(event) => event.stopPropagation()}
1723 />
1724 ) : (
1725 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1726 {project.name}
1727 </span>
1728 )}
1729 </SidebarMenuButton>
1730 <Tooltip>
1731 <TooltipTrigger
1732 render={
1733 <SidebarMenuAction
1734 render={
1735 <button
1736 type="button"
1737 aria-label={`Create new thread in ${project.name}`}
1738 data-testid="new-thread-button"
1739 />
1740 }
1741 showOnHover
1742 className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1743 onClick={(event) => {
1744 event.preventDefault();
1745 event.stopPropagation();
1746 const seedContext = resolveSidebarNewThreadSeedContext({
1747 projectId: project.id,
1748 defaultEnvMode: resolveSidebarNewThreadEnvMode({
1749 defaultEnvMode: appSettings.defaultThreadEnvMode,
1750 }),
1751 activeThread:
1752 activeThread && activeThread.projectId === project.id
1753 ? {
1754 projectId: activeThread.projectId,
1755 branch: activeThread.branch,
1756 worktreePath: activeThread.worktreePath,
1757 }
1758 : null,
1759 activeDraftThread:
1760 activeDraftThread && activeDraftThread.projectId === project.id
1761 ? {
1762 projectId: activeDraftThread.projectId,
1763 branch: activeDraftThread.branch,
1764 worktreePath: activeDraftThread.worktreePath,
1765 envMode: activeDraftThread.envMode,
1766 }
1767 : null,
1768 });
1769 void handleNewThread(project.id, {
1770 ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}),
1771 ...(seedContext.worktreePath !== undefined
1772 ? { worktreePath: seedContext.worktreePath }
1773 : {}),
1774 envMode: seedContext.envMode,
1775 });
1776 }}
1777 >
1778 <SquarePenIcon className="size-3.5" />
1779 </SidebarMenuAction>
1780 }
1781 />
1782 <TooltipPopup side="top">
1783 {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
1784 </TooltipPopup>
1785 </Tooltip>
1786 </div>
1787
1788 <SidebarMenuSub
1789 ref={attachThreadListAutoAnimateRef}
1790 className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0"
1791 >
1792 {shouldShowThreadPanel && showEmptyThreadState ? (
1793 <SidebarMenuSubItem className="w-full" data-thread-selection-safe>
1794 <div
1795 data-thread-selection-safe
1796 className="flex h-6 w-full translate-x-0 items-center px-2 text-left text-[10px] text-muted-foreground/60"
1797 >
1798 <span>No threads yet</span>
1799 </div>
1800 </SidebarMenuSubItem>
1801 ) : null}
1802 {shouldShowThreadPanel &&
1803 renderedThreadIds.map((threadId) => (
1804 <SidebarThreadRow
1805 key={threadId}
1806 threadId={threadId}
1807 projectCwd={project.cwd}
1808 orderedProjectThreadIds={orderedProjectThreadIds}
1809 routeThreadId={routeThreadId}
1810 selectedThreadIds={selectedThreadIds}
1811 showThreadJumpHints={showThreadJumpHints}
1812 jumpLabel={threadJumpLabelById.get(threadId) ?? null}
1813 appSettingsConfirmThreadArchive={appSettings.confirmThreadArchive}
1814 renamingThreadId={renamingThreadId}
1815 renamingTitle={renamingTitle}
1816 setRenamingTitle={setRenamingTitle}
1817 onRenamingInputMount={handleRenamingInputMount}
1818 hasRenameCommitted={hasRenameCommitted}
1819 markRenameCommitted={markRenameCommitted}
1820 confirmingArchiveThreadId={confirmingArchiveThreadId}
1821 setConfirmingArchiveThreadId={setConfirmingArchiveThreadId}
1822 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
1823 handleThreadClick={handleThreadClick}
1824 navigateToThread={navigateToThread}
1825 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
1826 handleThreadContextMenu={handleThreadContextMenu}
1827 clearSelection={clearSelection}
1828 commitRename={commitRename}
1829 cancelRename={cancelRename}
1830 attemptArchiveThread={attemptArchiveThread}
1831 openPrLink={openPrLink}
1832 />
1833 ))}
1834
1835 {project.expanded && hasHiddenThreads && !isThreadListExpanded && (
1836 <SidebarMenuSubItem className="w-full">
1837 <SidebarMenuSubButton
1838 render={<button type="button" />}
1839 data-thread-selection-safe
1840 size="sm"
1841 className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1842 onClick={() => {
1843 expandThreadListForProject(project.id);
1844 }}
1845 >
1846 <span className="flex min-w-0 flex-1 items-center gap-2">
1847 {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />}
1848 <span>Show more</span>
1849 </span>
1850 </SidebarMenuSubButton>
1851 </SidebarMenuSubItem>
1852 )}
1853 {project.expanded && hasHiddenThreads && isThreadListExpanded && (
1854 <SidebarMenuSubItem className="w-full">
1855 <SidebarMenuSubButton
1856 render={<button type="button" />}
1857 data-thread-selection-safe
1858 size="sm"
1859 className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1860 onClick={() => {
1861 collapseThreadListForProject(project.id);
1862 }}
1863 >
1864 <span>Show less</span>
1865 </SidebarMenuSubButton>
1866 </SidebarMenuSubItem>
1867 )}
1868 </SidebarMenuSub>
1869 </>
1870 );
1871 }
1872
1873 const handleProjectTitleClick = useCallback(
1874 (event: MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
1875 if (suppressProjectClickForContextMenuRef.current) {
1876 suppressProjectClickForContextMenuRef.current = false;
1877 event.preventDefault();
1878 event.stopPropagation();
1879 return;
1880 }
1881 if (dragInProgressRef.current) {
1882 event.preventDefault();
1883 event.stopPropagation();
1884 return;
1885 }
1886 if (suppressProjectClickAfterDragRef.current) {
1887 // Consume the synthetic click emitted after a drag release.
1888 suppressProjectClickAfterDragRef.current = false;
1889 event.preventDefault();
1890 event.stopPropagation();
1891 return;
1892 }
1893 if (selectedThreadIds.size > 0) {
1894 clearSelection();
1895 }
1896 toggleProject(projectId);
1897 },
1898 [clearSelection, selectedThreadIds.size, toggleProject],
1899 );
1900
1901 const handleProjectTitleKeyDown = useCallback(
1902 (event: KeyboardEvent<HTMLButtonElement>, projectId: ProjectId) => {
1903 if (event.key !== "Enter" && event.key !== " ") return;
1904 event.preventDefault();
1905 if (dragInProgressRef.current) {
1906 return;
1907 }
1908 toggleProject(projectId);
1909 },
1910 [toggleProject],
1911 );
1912
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() {...@@ -1922,7 +2771,7 @@ export default function Sidebar() {
1922 return () => {2771 return () => {
1923 window.removeEventListener("mousedown", onMouseDown);2772 window.removeEventListener("mousedown", onMouseDown);
1924 };2773 };
1925 }, [clearSelection, selectedThreadIds.size]);2774 }, [clearSelection, selectedThreadCount]);
19262775
1927 useEffect(() => {2776 useEffect(() => {
1928 if (!isElectron) return;2777 if (!isElectron) return;
...@@ -1967,10 +2816,6 @@ export default function Sidebar() {...@@ -1967,10 +2816,6 @@ export default function Sidebar() {
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() {...@@ -2033,246 +2878,82 @@ export default function Sidebar() {
2033 }2878 }
2034 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);2879 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);
20352880
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 }, []);
20442889
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 }, []);
20532898
2054 const wordmark = (
2055 <div className="flex items-center gap-2">
2056 <SidebarTrigger className="shrink-0 md:hidden" />
2057 <Tooltip>
2058 <TooltipTrigger
2059 render={
2060 <Link
2061 aria-label="Go to threads"
2062 className="ml-1 flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md outline-hidden ring-ring transition-colors hover:text-foreground focus-visible:ring-2"
2063 to="/"
2064 >
2065 <T3Wordmark />
2066 <span className="truncate text-sm font-medium tracking-tight text-muted-foreground">
2067 Code
2068 </span>
2069 <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground/60">
2070 {APP_STAGE_LABEL}
2071 </span>
2072 </Link>
2073 }
2074 />
2075 <TooltipPopup side="bottom" sideOffset={2}>
2076 Version {APP_VERSION}
2077 </TooltipPopup>
2078 </Tooltip>
2079 </div>
2080 );
2081
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 )}
20932902
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>
22592954
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 created+247
...@@ -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 @@...@@ -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 {...@@ -31,7 +32,8 @@ import {
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";
3638
37const MIN_DRAWER_HEIGHT = 180;39const MIN_DRAWER_HEIGHT = 180;
...@@ -208,6 +210,7 @@ export function shouldHandleTerminalSelectionMouseUp(...@@ -208,6 +210,7 @@ export function shouldHandleTerminalSelectionMouseUp(
208}210}
209211
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 {...@@ -222,7 +225,8 @@ interface TerminalViewportProps {
222 drawerHeight: number;225 drawerHeight: number;
223}226}
224227
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({...@@ -260,6 +264,9 @@ function TerminalViewport({
260 if (!mount) return;264 if (!mount) return;
261265
262 let disposed = false;266 let disposed = false;
267 const api = readEnvironmentApi(threadRef.environmentId);
268 const localApi = readLocalApi();
269 if (!api || !localApi) return;
263270
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({...@@ -277,9 +284,6 @@ function TerminalViewport({
277 terminalRef.current = terminal;284 terminalRef.current = terminal;
278 fitAddonRef.current = fitAddon;285 fitAddonRef.current = fitAddon;
279286
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({...@@ -340,7 +344,7 @@ function TerminalViewport({
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({...@@ -416,7 +420,7 @@ function TerminalViewport({
416 if (!latestTerminal) return;420 if (!latestTerminal) return;
417421
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({...@@ -426,7 +430,7 @@ function TerminalViewport({
426 }430 }
427431
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({...@@ -573,12 +577,12 @@ function TerminalViewport({
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({...@@ -608,7 +612,7 @@ function TerminalViewport({
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({...@@ -677,7 +681,7 @@ function TerminalViewport({
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]);
681685
682 useEffect(() => {686 useEffect(() => {
683 if (!autoFocus) return;687 if (!autoFocus) return;
...@@ -692,7 +696,7 @@ function TerminalViewport({...@@ -692,7 +696,7 @@ function TerminalViewport({
692 }, [autoFocus, focusRequestId]);696 }, [autoFocus, focusRequestId]);
693697
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({...@@ -714,13 +718,14 @@ function TerminalViewport({
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}
722726
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...@@ -773,6 +778,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA
773}778}
774779
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({...@@ -1098,6 +1104,7 @@ export default function ThreadTerminalDrawer({
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({...@@ -1119,6 +1126,7 @@ export default function ThreadTerminalDrawer({
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 @@...@@ -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";...@@ -15,6 +17,7 @@ import { SidebarTrigger } from "../ui/sidebar";
15import { OpenInPicker } from "./OpenInPicker";17import { OpenInPicker } from "./OpenInPicker";
1618
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 {...@@ -39,6 +42,7 @@ interface ChatHeaderProps {
39}42}
4043
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({...@@ -101,7 +105,12 @@ export const ChatHeader = memo(function ChatHeader({
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 @@...@@ -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";
39
4import { page } from "vitest/browser";10import { page } from "vitest/browser";
...@@ -9,35 +15,37 @@ import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";...@@ -9,35 +15,37 @@ import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
9import { TraitsMenuContent } from "./TraitsPicker";15import { TraitsMenuContent } from "./TraitsPicker";
10import { useComposerDraftStore } from "../../composerDraftStore";16import { useComposerDraftStore } from "../../composerDraftStore";
1117
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];
1926
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...@@ -121,7 +129,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
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", () => {...@@ -150,9 +158,9 @@ describe("CompactComposerControlsMenu", () => {
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 @@...@@ -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(...@@ -60,6 +60,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
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]);
6368
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(...@@ -70,22 +75,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
70 };75 };
71 }, []);76 }, []);
7277
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 );
8991
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(...@@ -112,7 +114,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
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]);
116118
117 if (!activeQuestion) {119 if (!activeQuestion) {
118 return null;120 return null;
apps/web/src/components/chat/MessagesTimeline.test.tsx+4
...@@ -42,6 +42,8 @@ beforeAll(() => {...@@ -42,6 +42,8 @@ beforeAll(() => {
42 });42 });
43});43});
4444
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", () => {...@@ -85,6 +87,7 @@ describe("MessagesTimeline", () => {
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", () => {...@@ -130,6 +133,7 @@ describe("MessagesTimeline", () => {
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 @@...@@ -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 {...@@ -82,6 +82,7 @@ interface MessagesTimelineProps {
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({...@@ -117,6 +118,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
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({...@@ -531,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
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 = {...@@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = {
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;
2223
23interface RowMeasurement {24interface RowMeasurement {
24 actualHeightPx: number;25 actualHeightPx: number;
...@@ -31,7 +32,10 @@ interface RowMeasurement {...@@ -31,7 +32,10 @@ interface RowMeasurement {
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}
3741
...@@ -48,7 +52,10 @@ interface VirtualizerSnapshot {...@@ -48,7 +52,10 @@ interface VirtualizerSnapshot {
48}52}
4953
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(...@@ -73,6 +80,7 @@ function MessagesTimelineBrowserHarness(
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: {...@@ -143,7 +151,7 @@ function createBaseTimelineProps(input: {
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>(...@@ -481,7 +489,10 @@ async function waitForElement<T extends Element>(
481489
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: {...@@ -550,7 +561,10 @@ async function measureTimelineRow(input: {
550}561}
551562
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: {...@@ -576,7 +590,10 @@ async function mountMessagesTimeline(input: {
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 {...@@ -18,7 +18,7 @@ import {
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";
2222
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({...@@ -93,7 +93,7 @@ export const OpenInPicker = memo(function OpenInPicker({
9393
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({...@@ -110,7 +110,7 @@ export const OpenInPicker = memo(function OpenInPicker({
110110
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 @@...@@ -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 {...@@ -24,15 +25,17 @@ import {
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";
2930
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({...@@ -82,7 +85,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
82 };85 };
8386
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 {...@@ -6,10 +6,11 @@ import {
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";...@@ -27,7 +28,13 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
2728
28// ── Claude TraitsPicker tests ─────────────────────────────────────────29// ── Claude TraitsPicker tests ─────────────────────────────────────────
2930
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: {...@@ -120,10 +127,10 @@ function ClaudeTraitsPickerHarness(props: {
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: {...@@ -135,7 +142,7 @@ function ClaudeTraitsPickerHarness(props: {
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: {...@@ -144,7 +151,7 @@ function ClaudeTraitsPickerHarness(props: {
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?: {...@@ -168,8 +175,8 @@ async function mountClaudePicker(props?: {
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?: {...@@ -192,9 +199,9 @@ async function mountClaudePicker(props?: {
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)", () => {...@@ -230,9 +237,9 @@ describe("TraitsPicker (Claude)", () => {
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)", () => {...@@ -369,10 +376,9 @@ describe("TraitsPicker (Claude)", () => {
369// ── Codex TraitsPicker tests ──────────────────────────────────────────376// ── Codex TraitsPicker tests ──────────────────────────────────────────
370377
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...@@ -392,10 +398,10 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt
392 };398 };
393399
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...@@ -404,7 +410,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt
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)", () => {...@@ -429,9 +435,9 @@ describe("TraitsPicker (Codex)", () => {
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 {...@@ -3,8 +3,8 @@ import {
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 {...@@ -28,18 +28,19 @@ import {
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";
3434
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 };
4546
...@@ -167,7 +168,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -167,7 +168,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
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 @@...@@ -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";
48
5const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [9const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [
6 {10 {
...@@ -417,3 +421,31 @@ describe("getComposerProviderState", () => {...@@ -417,3 +421,31 @@ describe("getComposerProviderState", () => {
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 @@...@@ -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 = {...@@ -33,7 +34,8 @@ export type ComposerProviderState = {
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 = {...@@ -41,7 +43,8 @@ type ProviderRegistryEntry = {
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 = {...@@ -50,6 +53,13 @@ type ProviderRegistryEntry = {
50 }) => ReactNode;53 }) => ReactNode;
51};54};
5255
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> = {...@@ -94,66 +104,92 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
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};
159195
...@@ -163,7 +199,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com...@@ -163,7 +199,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
163199
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: {...@@ -171,7 +208,8 @@ export function renderProviderTraitsMenuContent(input: {
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: {...@@ -182,7 +220,8 @@ export function renderProviderTraitsMenuContent(input: {
182220
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: {...@@ -190,7 +229,8 @@ export function renderProviderTraitsPicker(input: {
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 @@...@@ -1,17 +1,29 @@
1import "../../index.css";1import "../../index.css";
22
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";
712
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";
1217
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 {...@@ -32,14 +44,14 @@ function createBaseServerConfig(): ServerConfig {
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 });
3951
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 });
4557
...@@ -68,12 +80,12 @@ describe("GeneralSettingsPanel observability", () => {...@@ -68,12 +80,12 @@ describe("GeneralSettingsPanel observability", () => {
68 });80 });
6981
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;
7789
78 setServerConfigSnapshot(createBaseServerConfig());90 setServerConfigSnapshot(createBaseServerConfig());
7991
apps/web/src/components/settings/SettingsPanels.tsx+36-35
...@@ -13,11 +13,12 @@ import { useQueryClient } from "@tanstack/react-query";...@@ -13,11 +13,12 @@ import { useQueryClient } from "@tanstack/react-query";
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 {...@@ -45,8 +46,12 @@ import {
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) {...@@ -496,8 +501,8 @@ export function useSettingsRestore(onRestored?: () => void) {
496501
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() {...@@ -554,7 +559,7 @@ export function GeneralSettingsPanel() {
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() {...@@ -614,7 +619,7 @@ export function GeneralSettingsPanel() {
614 return;619 return;
615 }620 }
616621
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() {...@@ -1479,20 +1484,10 @@ export function GeneralSettingsPanel() {
1479}1484}
14801485
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() {...@@ -1505,11 +1500,11 @@ export function ArchivedThreadsPanel() {
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]);
15091504
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() {...@@ -1521,7 +1516,7 @@ export function ArchivedThreadsPanel() {
15211516
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() {...@@ -1533,7 +1528,7 @@ export function ArchivedThreadsPanel() {
1533 }1528 }
15341529
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() {...@@ -1566,10 +1561,13 @@ export function ArchivedThreadsPanel() {
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() {...@@ -1586,13 +1584,16 @@ export function ArchivedThreadsPanel() {
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 @@...@@ -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";...@@ -9,10 +16,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
916
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: {...@@ -71,9 +82,9 @@ function makeTerminalContext(input: {
7182
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...@@ -95,8 +106,32 @@ function providerModelOptions(options: ProviderModelOptions): ProviderModelOptio
95 return options;106 return options;
96}107}
97108
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>>;
102137
...@@ -129,9 +164,9 @@ describe("composerDraftStore addImages", () => {...@@ -129,9 +164,9 @@ describe("composerDraftStore addImages", () => {
129 lastModified: 12345,164 lastModified: 12345,
130 });165 });
131166
132 useComposerDraftStore.getState().addImages(threadId, [first, duplicate]);167 useComposerDraftStore.getState().addImages(threadRef, [first, duplicate]);
133168
134 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];169 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
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", () => {...@@ -154,10 +189,10 @@ describe("composerDraftStore addImages", () => {
154 lastModified: 999,189 lastModified: 999,
155 });190 });
156191
157 useComposerDraftStore.getState().addImage(threadId, first);192 useComposerDraftStore.getState().addImage(threadRef, first);
158 useComposerDraftStore.getState().addImage(threadId, duplicateLater);193 useComposerDraftStore.getState().addImage(threadRef, duplicateLater);
159194
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", () => {...@@ -172,9 +207,9 @@ describe("composerDraftStore addImages", () => {
172 previewUrl: "blob:shared",207 previewUrl: "blob:shared",
173 });208 });
174209
175 useComposerDraftStore.getState().addImages(threadId, [first, duplicateSameUrl]);210 useComposerDraftStore.getState().addImages(threadRef, [first, duplicateSameUrl]);
176211
177 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];212 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
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", () => {...@@ -182,6 +217,7 @@ describe("composerDraftStore addImages", () => {
182217
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>>;
187223
...@@ -201,11 +237,11 @@ describe("composerDraftStore clearComposerContent", () => {...@@ -201,11 +237,11 @@ describe("composerDraftStore clearComposerContent", () => {
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);
205241
206 useComposerDraftStore.getState().clearComposerContent(threadId);242 useComposerDraftStore.getState().clearComposerContent(threadRef);
207243
208 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];244 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
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", () => {...@@ -213,13 +249,14 @@ describe("composerDraftStore clearComposerContent", () => {
213249
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);
216253
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", () => {...@@ -234,7 +271,7 @@ describe("composerDraftStore syncPersistedAttachments", () => {
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", () => {...@@ -250,7 +287,7 @@ describe("composerDraftStore syncPersistedAttachments", () => {
250 Schema.Unknown,287 Schema.Unknown,
251 );288 );
252289
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", () => {...@@ -261,23 +298,20 @@ describe("composerDraftStore syncPersistedAttachments", () => {
261 ]);298 ]);
262 await Promise.resolve();299 await Promise.resolve();
263300
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});
272305
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);
275309
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", () => {...@@ -287,20 +321,20 @@ describe("composerDraftStore terminal contexts", () => {
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" });
289323
290 useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]);324 useComposerDraftStore.getState().addTerminalContexts(threadRef, [first, duplicate]);
291325
292 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];326 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
293 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]);327 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]);
294 });328 });
295329
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" }));
300334
301 useComposerDraftStore.getState().clearComposerContent(threadId);335 useComposerDraftStore.getState().clearComposerContent(threadRef);
302336
303 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();337 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
304 });338 });
305339
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", () => {...@@ -311,7 +345,7 @@ describe("composerDraftStore terminal contexts", () => {
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", () => {...@@ -319,7 +353,7 @@ describe("composerDraftStore terminal contexts", () => {
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", () => {...@@ -331,7 +365,7 @@ describe("composerDraftStore terminal contexts", () => {
331 ),365 ),
332 ).toBe(true);366 ).toBe(true);
333367
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", () => {...@@ -341,7 +375,7 @@ describe("composerDraftStore terminal contexts", () => {
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" }));
345379
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", () => {...@@ -349,11 +383,12 @@ describe("composerDraftStore terminal contexts", () => {
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 };
354388
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", () => {...@@ -363,7 +398,8 @@ describe("composerDraftStore terminal contexts", () => {
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 });
369405
...@@ -396,12 +432,12 @@ describe("composerDraftStore terminal contexts", () => {...@@ -396,12 +432,12 @@ describe("composerDraftStore terminal contexts", () => {
396 },432 },
397 },433 },
398 draftThreadsByThreadId: {},434 draftThreadsByThreadId: {},
399 projectDraftThreadIdByProjectId: {},435 projectDraftThreadIdByProjectKey: {},
400 },436 },
401 useComposerDraftStore.getInitialState(),437 useComposerDraftStore.getInitialState(),
402 );438 );
403439
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", () => {...@@ -434,22 +470,30 @@ describe("composerDraftStore terminal contexts", () => {
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 );
441477
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});
447483
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");
453497
454 beforeEach(() => {498 beforeEach(() => {
455 resetComposerDraftStore();499 resetComposerDraftStore();
...@@ -457,17 +501,20 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -457,17 +501,20 @@ describe("composerDraftStore project draft thread mapping", () => {
457501
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();
462506
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", () => {...@@ -475,8 +522,10 @@ describe("composerDraftStore project draft thread mapping", () => {
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", () => {...@@ -488,134 +537,210 @@ describe("composerDraftStore project draft thread mapping", () => {
488537
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");
493542
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 );
498547
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 });
504553
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 });
514563
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");
519568
520 store.setProjectDraftThreadId(projectId, otherThreadId);569 store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId });
521570
522 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(571 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
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 });
528577
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");
534583
535 store.clearProjectDraftThreadId(projectId);584 store.clearProjectDraftThreadId(projectRef);
536585
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 });
543592
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 });
553616
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");
558620
559 clearPromotedDraftThread(threadId);621 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
622 store.setPrompt(draftId, "scoped access");
560623
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 });
565627
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");
569632
570 clearPromotedDraftThread(threadId);633 markPromotedDraftThread(threadId);
571634
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 });
575638
576 it("clears promoted drafts from an iterable of server thread ids", () => {639 it("marks promoted drafts from an iterable of server thread ids", () => {
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");
582645
583 clearPromotedDraftThreads([threadId]);646 markPromotedDraftThreads([threadId]);
584647
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 });
594703
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");
598708
599 clearPromotedDraftThreads([threadId]);709 markPromotedDraftThreads([threadId]);
600710
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 });
604727
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", () => {...@@ -625,7 +750,8 @@ describe("composerDraftStore project draft thread mapping", () => {
625750
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", () => {...@@ -636,9 +762,10 @@ describe("composerDraftStore project draft thread mapping", () => {
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);
640766
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", () => {...@@ -648,7 +775,8 @@ describe("composerDraftStore project draft thread mapping", () => {
648775
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", () => {...@@ -662,9 +790,10 @@ describe("composerDraftStore project draft thread mapping", () => {
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);
666794
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", () => {...@@ -675,6 +804,7 @@ describe("composerDraftStore project draft thread mapping", () => {
675804
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);
678808
679 beforeEach(() => {809 beforeEach(() => {
680 resetComposerDraftStore();810 resetComposerDraftStore();
...@@ -683,16 +813,14 @@ describe("composerDraftStore modelSelection", () => {...@@ -683,16 +813,14 @@ describe("composerDraftStore modelSelection", () => {
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 );
692822
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", () => {...@@ -702,18 +830,18 @@ describe("composerDraftStore modelSelection", () => {
702830
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"));
706834
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 });
711839
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();
714842
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", () => {...@@ -727,7 +855,7 @@ describe("composerDraftStore modelSelection", () => {
727 );855 );
728856
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", () => {...@@ -735,10 +863,7 @@ describe("composerDraftStore modelSelection", () => {
735 { persistSticky: true },863 { persistSticky: true },
736 );864 );
737865
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", () => {...@@ -754,20 +879,17 @@ describe("composerDraftStore modelSelection", () => {
754 const store = useComposerDraftStore.getState();879 const store = useComposerDraftStore.getState();
755880
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 );
762887
763 store.setProviderModelOptions(threadId, "claudeAgent", {888 store.setProviderModelOptions(threadRef, "claudeAgent", {
764 thinking: true,889 thinking: true,
765 });890 });
766891
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", () => {...@@ -778,16 +900,14 @@ describe("composerDraftStore modelSelection", () => {
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();
780902
781 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true }));903 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true }));
782904
783 store.setProviderModelOptions(threadId, "codex", {905 store.setProviderModelOptions(threadRef, "codex", {
784 reasoningEffort: "high",906 reasoningEffort: "high",
785 fastMode: false,907 fastMode: false,
786 });908 });
787909
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", () => {...@@ -802,18 +922,15 @@ describe("composerDraftStore modelSelection", () => {
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 );
808928
809 store.setProviderModelOptions(threadId, "claudeAgent", {929 store.setProviderModelOptions(threadRef, "claudeAgent", {
810 thinking: false,930 thinking: false,
811 });931 });
812932
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", () => {...@@ -828,7 +945,7 @@ describe("composerDraftStore modelSelection", () => {
828945
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", () => {...@@ -836,9 +953,9 @@ describe("composerDraftStore modelSelection", () => {
836 );953 );
837954
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" } }));
840957
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", () => {...@@ -847,16 +964,16 @@ describe("composerDraftStore modelSelection", () => {
847 const store = useComposerDraftStore.getState();964 const store = useComposerDraftStore.getState();
848965
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 );
856973
857 store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6"));974 store.setModelSelection(threadRef, modelSelection("claudeAgent", "claude-opus-4-6"));
858975
859 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];976 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
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", () => {...@@ -867,10 +984,10 @@ describe("composerDraftStore modelSelection", () => {
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();
869986
870 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4"));987 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4"));
871988
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", () => {...@@ -892,12 +1009,12 @@ describe("composerDraftStore modelSelection", () => {
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 );
8981015
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", () => {...@@ -905,10 +1022,7 @@ describe("composerDraftStore modelSelection", () => {
905 { persistSticky: false },1022 { persistSticky: false },
906 );1023 );
9071024
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", () => {...@@ -921,6 +1035,7 @@ describe("composerDraftStore modelSelection", () => {
9211035
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);
9241039
925 beforeEach(() => {1040 beforeEach(() => {
926 resetComposerDraftStore();1041 resetComposerDraftStore();
...@@ -929,11 +1044,11 @@ describe("composerDraftStore setModelSelection", () => {...@@ -929,11 +1044,11 @@ describe("composerDraftStore setModelSelection", () => {
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();
9311046
932 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.3-codex"));1047 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.3-codex"));
9331048
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});
9391054
...@@ -975,11 +1090,12 @@ describe("composerDraftStore sticky composer settings", () => {...@@ -975,11 +1090,12 @@ describe("composerDraftStore sticky composer settings", () => {
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);
9781094
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);
9811097
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", () => {...@@ -990,6 +1106,7 @@ describe("composerDraftStore sticky composer settings", () => {
9901106
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);
9931110
994 beforeEach(() => {1111 beforeEach(() => {
995 resetComposerDraftStore();1112 resetComposerDraftStore();
...@@ -998,13 +1115,13 @@ describe("composerDraftStore provider-scoped option updates", () => {...@@ -998,13 +1115,13 @@ describe("composerDraftStore provider-scoped option updates", () => {
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", () => {...@@ -1015,6 +1132,7 @@ describe("composerDraftStore provider-scoped option updates", () => {
10151132
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);
10181136
1019 beforeEach(() => {1137 beforeEach(() => {
1020 resetComposerDraftStore();1138 resetComposerDraftStore();
...@@ -1023,32 +1141,28 @@ describe("composerDraftStore runtime and interaction settings", () => {...@@ -1023,32 +1141,28 @@ describe("composerDraftStore runtime and interaction settings", () => {
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();
10251143
1026 store.setRuntimeMode(threadId, "approval-required");1144 store.setRuntimeMode(threadRef, "approval-required");
10271145
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 });
10321148
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();
10351151
1036 store.setInteractionMode(threadId, "plan");1152 store.setInteractionMode(threadRef, "plan");
10371153
1038 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.interactionMode).toBe(1154 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.interactionMode).toBe("plan");
1039 "plan",
1040 );
1041 });1155 });
10421156
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();
10451159
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);
10501164
1051 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();1165 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
1052 });1166 });
1053});1167});
10541168
apps/web/src/composerDraftStore.ts+1572-948
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
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 {...@@ -10,8 +11,18 @@ import {
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";...@@ -32,10 +43,13 @@ import { getDefaultServerModel } from "./providerModels";
32import { UnifiedSettings } from "@t3tools/contracts/settings";43import { UnifiedSettings } from "@t3tools/contracts/settings";
3344
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;
3849
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;
4054
41const composerDebouncedStorage = createDebouncedStorage(55const composerDebouncedStorage = createDebouncedStorage(
...@@ -44,7 +58,7 @@ const composerDebouncedStorage = createDebouncedStorage(...@@ -44,7 +58,7 @@ const composerDebouncedStorage = createDebouncedStorage(
44);58);
4559
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;...@@ -122,6 +136,14 @@ type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type;
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};
126148
127type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState &149type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState &
...@@ -129,20 +151,31 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState &...@@ -129,20 +151,31 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState &
129 LegacyV2StoreFields;151 LegacyV2StoreFields;
130152
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;
141174
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({...@@ -155,6 +188,10 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({
155 state: PersistedComposerDraftStoreState,188 state: PersistedComposerDraftStoreState,
156});189});
157190
191/**
192 * Composer content keyed by either a draft session (`DraftId`) or a real server
193 * thread (`ScopedThreadRef`). This is the editable payload shown in the composer.
194 */
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 {...@@ -167,32 +204,94 @@ export interface ComposerThreadDraftState {
167 interactionMode: ProviderInteractionMode | null;204 interactionMode: ProviderInteractionMode | null;
168}205}
169206
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}
179226
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}
183235
236/**
237 * App-facing composer identity:
238 * - `DraftId` for pre-thread draft sessions
239 * - `ScopedThreadRef` for server-backed threads
240 *
241 * Raw `ThreadId` is intentionally excluded so callers cannot drop environment
242 * identity for real threads.
243 */
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 {...@@ -201,65 +300,76 @@ interface ComposerDraftStoreState {
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}
264374
265export interface EffectiveComposerModelState {375export interface EffectiveComposerModelState {
...@@ -293,9 +403,9 @@ function modelSelectionByProviderToOptions(...@@ -293,9 +403,9 @@ function modelSelectionByProviderToOptions(
293}403}
294404
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(...@@ -748,35 +858,323 @@ function normalizeDraftThreadEnvMode(
748 return fallbackWorktreePath ? "worktree" : "local";858 return fallbackWorktreePath ? "worktree" : "local";
749}859}
750860
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(...@@ -794,59 +1192,97 @@ function normalizePersistedDraftThreads(
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 }
8001199
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 }
8351256
836 return { draftThreadsByThreadId, projectDraftThreadIdByProjectId };1257 return { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey };
837}1258}
8381259
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 }
8451267
846 const nextDraftsByThreadId: DeepMutable<PersistedComposerDraftStoreState["draftsByThreadId"]> =1268 const environmentIdByThreadId = new Map<ThreadId, EnvironmentId>();
1269 for (const [threadKey, draftThread] of Object.entries(draftThreadsByThreadKey)) {
1270 const parsedThreadRef = composerThreadRefFromKey(threadKey);
1271 if (!parsedThreadRef) {
1272 continue;
1273 }
1274 environmentIdByThreadId.set(
1275 parsedThreadRef.threadId,
1276 draftThread.environmentId as EnvironmentId,
1277 );
1278 }
1279
1280 const nextDraftsByThreadKey: DeepMutable<PersistedComposerDraftStoreState["draftsByThreadKey"]> =
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(...@@ -937,7 +1373,19 @@ function normalizePersistedDraftsByThreadId(
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(...@@ -947,7 +1395,7 @@ function normalizePersistedDraftsByThreadId(
947 };1395 };
948 }1396 }
9491397
950 return nextDraftsByThreadId;1398 return nextDraftsByThreadKey;
951}1399}
9521400
953function migratePersistedComposerDraftStoreState(1401function migratePersistedComposerDraftStoreState(
...@@ -957,9 +1405,14 @@ function migratePersistedComposerDraftStoreState(...@@ -957,9 +1405,14 @@ function migratePersistedComposerDraftStoreState(
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;
9631416
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(...@@ -982,13 +1435,16 @@ function migratePersistedComposerDraftStoreState(
982 );1435 );
983 const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null;1436 const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null;
9841437
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(...@@ -997,11 +1453,11 @@ function migratePersistedComposerDraftStoreState(
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(...@@ -1041,12 +1497,13 @@ function partializeComposerDraftStoreState(
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(...@@ -1059,10 +1516,14 @@ function normalizeCurrentPersistedComposerDraftStoreState(
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 );
10671528
1068 // Handle both v3 (modelSelectionByProvider) and v2/legacy formats1529 // Handle both v3 (modelSelectionByProvider) and v2/legacy formats
...@@ -1105,16 +1566,19 @@ function normalizeCurrentPersistedComposerDraftStoreState(...@@ -1105,16 +1566,19 @@ function normalizeCurrentPersistedComposerDraftStoreState(
1105 }1566 }
11061567
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}
11151579
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[] {...@@ -1125,7 +1589,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
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[] {...@@ -1134,7 +1598,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
1134}1598}
11351599
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(...@@ -1149,12 +1613,12 @@ function verifyPersistedAttachments(
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(...@@ -1170,13 +1634,13 @@ function verifyPersistedAttachments(
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}
11821646
...@@ -1258,879 +1722,980 @@ function toHydratedThreadDraft(...@@ -1258,879 +1722,980 @@ function toHydratedThreadDraft(
1258 };1722 };
1259}1723}
12601724
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],
17262226 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
17302230 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];
17442265
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 }
17692283
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 }
17772308
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 }
17882316
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>()(...@@ -2140,17 +2705,23 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
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>()(...@@ -2159,19 +2730,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
2159 ),2730 ),
2160);2731);
21612732
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}
21652740
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(""));
21752751
2176 return useMemo(2752 return useMemo(
2177 () =>2753 () =>
...@@ -2195,21 +2771,69 @@ export function useEffectiveComposerModelState(input: {...@@ -2195,21 +2771,69 @@ export function useEffectiveComposerModelState(input: {
2195}2771}
21962772
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}
22102807
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 @@...@@ -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";
44
...@@ -26,7 +26,7 @@ export function resolveAndPersistPreferredEditor(...@@ -26,7 +26,7 @@ export function resolveAndPersistPreferredEditor(
26 return editor ?? null;26 return editor ?? null;
27}27}
2828
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 created+67
...@@ -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 created+65
...@@ -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 @@...@@ -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 {...@@ -7,37 +8,49 @@ import {
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";
1518
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]);
3750
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() {...@@ -45,84 +58,110 @@ export function useHandleNewThread() {
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 }
81110
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 }
99134
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);
111148
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 );
120157
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 {...@@ -26,7 +26,7 @@ import {
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>): {...@@ -67,9 +67,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): {
67 * only re-render when the slice they care about changes.67 * only re-render when the slice they care about changes.
68 */68 */
6969
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() {...@@ -111,7 +109,7 @@ export function useUpdateSettings() {
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 }
116114
117 if (Object.keys(clientPatch).length > 0) {115 if (Object.keys(clientPatch).length > 0) {
...@@ -239,7 +237,7 @@ export function migrateLocalSettingsToServer(): void {...@@ -239,7 +237,7 @@ export function migrateLocalSettingsToServer(): void {
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 }
245243
apps/web/src/hooks/useThreadActions.ts+128-64
...@@ -1,42 +1,63 @@...@@ -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";
56
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";
1725
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]);
3353
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() {...@@ -44,48 +65,69 @@ export function useThreadActions() {
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();
4971
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 );
5681
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 }, []);
6691
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() {...@@ -99,44 +141,60 @@ export function useThreadActions() {
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 }
107149
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 }
113155
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);
130178
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 }
142200
...@@ -145,15 +203,18 @@ export function useThreadActions() {...@@ -145,15 +203,18 @@ export function useThreadActions() {
145 }203 }
146204
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() {...@@ -169,22 +230,25 @@ export function useThreadActions() {
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 );
178240
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;
185249
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() {...@@ -195,9 +259,9 @@ export function useThreadActions() {
195 }259 }
196 }260 }
197261
198 await deleteThread(threadId);262 await deleteThread(target);
199 },263 },
200 [appSettings.confirmThreadDelete, deleteThread],264 [confirmThreadDelete, deleteThread, resolveThreadTarget],
201 );265 );
202266
203 return {267 return {
apps/web/src/lib/gitReactQuery.test.ts+30-13
...@@ -1,16 +1,17 @@...@@ -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";
33
4vi.mock("../nativeApi", () => ({4vi.mock("../environmentApi", () => ({
5 ensureNativeApi: vi.fn(),5 ensureEnvironmentApi: vi.fn(),
6}));6}));
77
8vi.mock("../wsRpcClient", () => ({8vi.mock("../wsRpcClient", () => ({
9 getWsRpcClient: vi.fn(),9 getWsRpcClient: vi.fn(),
10 getWsRpcClientForEnvironment: vi.fn(),
10}));11}));
1112
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";
1415
15import {16import {
16 gitBranchSearchInfiniteQueryOptions,17 gitBranchSearchInfiniteQueryOptions,
...@@ -33,21 +34,25 @@ const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = {...@@ -33,21 +34,25 @@ const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = {
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");
3639
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 });
4346
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 });
4752
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", () => {...@@ -57,23 +62,31 @@ describe("git mutation options", () => {
5762
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 });
6571
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 });
7080
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});
7992
...@@ -83,6 +96,7 @@ describe("invalidateGitQueries", () => {...@@ -83,6 +96,7 @@ describe("invalidateGitQueries", () => {
8396
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", () => {...@@ -90,17 +104,19 @@ describe("invalidateGitQueries", () => {
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 );
98113
99 await invalidateGitQueries(queryClient, { cwd: "/repo/a" });114 await invalidateGitQueries(queryClient, { environmentId: ENVIRONMENT_A, cwd: "/repo/a" });
100115
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", () => {...@@ -109,6 +125,7 @@ describe("invalidateGitQueries", () => {
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 @@...@@ -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 {...@@ -9,8 +10,8 @@ import {
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";
1415
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;...@@ -18,38 +19,52 @@ const GIT_BRANCHES_PAGE_SIZE = 100;
1819
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};
2527
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};
3440
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 }
4050
41 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });51 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
42}52}
4353
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 }
4862
49 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) });63 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) });
50}64}
5165
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: {...@@ -57,11 +72,12 @@ export function gitBranchSearchInfiniteQueryOptions(input: {
57 const normalizedQuery = input.query.trim();72 const normalizedQuery = input.query.trim();
5873
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: {...@@ -79,62 +95,75 @@ export function gitBranchSearchInfiniteQueryOptions(input: {
79}95}
8096
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}
100123
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}
114141
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}
131159
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: {...@@ -150,8 +179,8 @@ export function gitRunStackedActionMutationOptions(input: {
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: {...@@ -164,62 +193,85 @@ export function gitRunStackedActionMutationOptions(input: {
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}
171200
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}
185218
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}
197238
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}
209258
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: {...@@ -228,7 +280,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: {
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 @@...@@ -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";
33
4import {4import {
...@@ -16,6 +16,10 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even...@@ -16,6 +16,10 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even
16}16}
1717
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;
1923
20const BASE_STATUS: GitStatusResult = {24const BASE_STATUS: GitStatusResult = {
21 isRepo: true,25 isRepo: true,
...@@ -55,7 +59,7 @@ afterEach(() => {...@@ -55,7 +59,7 @@ afterEach(() => {
5559
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", () => {...@@ -64,11 +68,11 @@ describe("gitStatusState", () => {
64 });68 });
6569
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);
6973
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", () => {...@@ -77,7 +81,7 @@ describe("gitStatusState", () => {
7781
78 emitGitStatus(BASE_STATUS);82 emitGitStatus(BASE_STATUS);
7983
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", () => {...@@ -92,15 +96,15 @@ describe("gitStatusState", () => {
92 });96 });
9397
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);
96100
97 emitGitStatus(BASE_STATUS);101 emitGitStatus(BASE_STATUS);
98 const refreshed = await refreshGitStatus("/repo", gitClient);102 const refreshed = await refreshGitStatus(TARGET, gitClient);
99103
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", () => {...@@ -109,4 +113,38 @@ describe("gitStatusState", () => {
109113
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 @@...@@ -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";
610
7import { appAtomRegistry } from "../rpc/atomRegistry";11import { appAtomRegistry } from "../rpc/atomRegistry";
8import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient";12import { getWsRpcClient, getWsRpcClientForEnvironment, type WsRpcClient } from "../wsRpcClient";
913
10export type GitStatusStreamError = GitManagerServiceError;14export type GitStatusStreamError = GitManagerServiceError;
1115
...@@ -23,6 +27,11 @@ interface WatchedGitStatus {...@@ -23,6 +27,11 @@ interface WatchedGitStatus {
23 unsubscribe: () => void;27 unsubscribe: () => void;
24}28}
2529
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(...@@ -40,79 +49,91 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe(
4049
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>();
4655
47const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;56const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
4857
49let sharedGitStatusClient: GitStatusClient | null = null;58const gitStatusStateAtom = Atom.family((key: string) => {
5059 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});
5865
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 }
6386
64 return appAtomRegistry.get(gitStatusStateAtom(cwd));87 return appAtomRegistry.get(gitStatusStateAtom(targetKey));
65}88}
6689
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 }
7498
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 }
82104
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 });
87109
88 return () => unwatchGitStatus(cwd);110 return () => unwatchGitStatus(targetKey);
89}111}
90112
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 }
98121
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 }
105126
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 }
110131
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}
118139
...@@ -122,43 +143,29 @@ export function resetGitStatusStateForTests(): void {...@@ -122,43 +143,29 @@ export function resetGitStatusStateForTests(): void {
122 }143 }
123 watchedGitStatuses.clear();144 watchedGitStatuses.clear();
124 gitStatusRefreshInFlight.clear();145 gitStatusRefreshInFlight.clear();
125 gitStatusLastRefreshAtByCwd.clear();146 gitStatusLastRefreshAtByKey.clear();
126 sharedGitStatusClient = null;
127147
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}
140153
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 }),
145158 [target.environmentId, target.cwd],
146 if (sharedGitStatusClient !== null) {159 );
147 resetLiveGitStatusSubscriptions();
148 }
149
150 sharedGitStatusClient = client;
151}
152160
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}
159166
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 {...@@ -169,20 +176,15 @@ function unwatchGitStatus(cwd: string): void {
169 }176 }
170177
171 watched.unsubscribe();178 watched.unsubscribe();
172 watchedGitStatuses.delete(cwd);179 watchedGitStatuses.delete(targetKey);
173}180}
174181
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 {...@@ -191,14 +193,14 @@ function subscribeToGitStatus(cwd: string): () => void {
191 },193 },
192 {194 {
193 onResubscribe: () => {195 onResubscribe: () => {
194 markGitStatusPending(cwd);196 markGitStatusPending(targetKey);
195 },197 },
196 },198 },
197 );199 );
198}200}
199201
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 @@...@@ -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";
44
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};
1014
11const DEFAULT_SEARCH_ENTRIES_LIMIT = 80;15const DEFAULT_SEARCH_ENTRIES_LIMIT = 80;
...@@ -16,6 +20,7 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {...@@ -16,6 +20,7 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {
16};20};
1721
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: {...@@ -24,19 +29,23 @@ export function projectSearchEntriesQueryOptions(input: {
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 @@...@@ -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";
66
7const threadId = ThreadId.makeUnsafe("thread-id");7const threadId = ThreadId.makeUnsafe("thread-id");
8const environmentId = EnvironmentId.makeUnsafe("environment-local");
89
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}
2021
21afterEach(() => {22afterEach(() => {
...@@ -25,6 +26,7 @@ afterEach(() => {...@@ -25,6 +26,7 @@ afterEach(() => {
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", () => {...@@ -51,6 +53,7 @@ describe("checkpointDiffQueryOptions", () => {
51 mockNativeApi({ getTurnDiff, getFullThreadDiff });53 mockNativeApi({ getTurnDiff, getFullThreadDiff });
5254
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", () => {...@@ -74,6 +77,7 @@ describe("checkpointDiffQueryOptions", () => {
74 mockNativeApi({ getTurnDiff, getFullThreadDiff });77 mockNativeApi({ getTurnDiff, getFullThreadDiff });
7578
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", () => {...@@ -96,6 +100,7 @@ describe("checkpointDiffQueryOptions", () => {
96 mockNativeApi({ getTurnDiff, getFullThreadDiff });100 mockNativeApi({ getTurnDiff, getFullThreadDiff });
97101
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", () => {...@@ -113,6 +118,7 @@ describe("checkpointDiffQueryOptions", () => {
113118
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", () => {...@@ -137,6 +143,7 @@ describe("checkpointDiffQueryOptions", () => {
137143
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 @@...@@ -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";
910
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 = {...@@ -21,6 +23,7 @@ export const providerQueryKeys = {
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) {...@@ -95,10 +98,10 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) {
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) {...@@ -108,7 +111,11 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) {
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 @@...@@ -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";
34
4import { collectActiveTerminalThreadIds } from "./terminalStateCleanup";5import { collectActiveTerminalThreadIds } from "./terminalStateCleanup";
56
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)));
710
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 });
1720
18 expect(activeThreadIds).toEqual(new Set([threadId("server-1"), threadId("server-2")]));21 expect(activeThreadIds).toEqual(
22 new Set([threadKey("env-a", "server-1"), threadKey("env-b", "server-2")]),
23 );
19 });24 });
2025
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 });
3843
39 expect(activeThreadIds).toEqual(new Set([threadId("server-active"), threadId("local-draft")]));44 expect(activeThreadIds).toEqual(
45 new Set([threadKey("env-a", "server-active"), threadKey("env-a", "local-draft")]),
46 );
40 });47 });
4148
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");
4451
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 });
5562
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 @@...@@ -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}
86
9interface CollectActiveTerminalThreadIdsInput {7interface CollectActiveTerminalThreadIdsInput {
10 snapshotThreads: readonly TerminalRetentionThread[];8 snapshotThreads: readonly TerminalRetentionThread[];
11 draftThreadIds: Iterable<ThreadId>;9 draftThreadKeys: Iterable<string>;
12}10}
1311
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 @@...@@ -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}));
210
3import { isWindowsPlatform } from "./utils";11import { isWindowsPlatform } from "./utils";
12import { resolveServerUrl } from "./utils";
413
5describe("isWindowsPlatform", () => {14describe("isWindowsPlatform", () => {
6 it("matches Windows platform identifiers", () => {15 it("matches Windows platform identifiers", () => {
...@@ -13,3 +22,34 @@ describe("isWindowsPlatform", () => {...@@ -13,3 +22,34 @@ describe("isWindowsPlatform", () => {
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";...@@ -4,6 +4,8 @@ import { type CxOptions, cx } from "class-variance-authority";
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";
79
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());...@@ -34,17 +36,11 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID());
3436
35export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());37export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());
3638
39export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID());
40
37export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());41export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());
3842
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};
4844
49export const resolveServerUrl = (options?: {45export const resolveServerUrl = (options?: {
50 url?: string | undefined;46 url?: string | undefined;
...@@ -52,12 +48,9 @@ export const resolveServerUrl = (options?: {...@@ -52,12 +48,9 @@ export const resolveServerUrl = (options?: {
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 );
6154
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.ts created+444
...@@ -0,0 +1,444 @@
1import {
2 CommandId,
3 DEFAULT_SERVER_SETTINGS,
4 type DesktopBridge,
5 EnvironmentId,
6 EventId,
7 type GitStatusResult,
8 ProjectId,
9 type OrchestrationEvent,
10 type ServerConfig,
11 type ServerProvider,
12 type TerminalEvent,
13 ThreadId,
14} from "@t3tools/contracts";
15import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
17import type { ContextMenuItem } from "@t3tools/contracts";
18
19const showContextMenuFallbackMock =
20 vi.fn<
21 <T extends string>(
22 items: readonly ContextMenuItem<T>[],
23 position?: { x: number; y: number },
24 ) => Promise<T | null>
25 >();
26
27function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
28 listeners.add(listener);
29 return () => {
30 listeners.delete(listener);
31 };
32}
33
34const terminalEventListeners = new Set<(event: TerminalEvent) => void>();
35const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>();
36const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
37
38const rpcClientMock = {
39 dispose: vi.fn(),
40 terminal: {
41 open: vi.fn(),
42 write: vi.fn(),
43 resize: vi.fn(),
44 clear: vi.fn(),
45 restart: vi.fn(),
46 close: vi.fn(),
47 onEvent: vi.fn((listener: (event: TerminalEvent) => void) =>
48 registerListener(terminalEventListeners, listener),
49 ),
50 },
51 projects: {
52 searchEntries: vi.fn(),
53 writeFile: vi.fn(),
54 },
55 shell: {
56 openInEditor: vi.fn(),
57 },
58 git: {
59 pull: vi.fn(),
60 refreshStatus: vi.fn(),
61 onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) =>
62 registerListener(gitStatusListeners, listener),
63 ),
64 runStackedAction: vi.fn(),
65 listBranches: vi.fn(),
66 createWorktree: vi.fn(),
67 removeWorktree: vi.fn(),
68 createBranch: vi.fn(),
69 checkout: vi.fn(),
70 init: vi.fn(),
71 resolvePullRequest: vi.fn(),
72 preparePullRequestThread: vi.fn(),
73 },
74 server: {
75 getConfig: vi.fn(),
76 refreshProviders: vi.fn(),
77 upsertKeybinding: vi.fn(),
78 getSettings: vi.fn(),
79 updateSettings: vi.fn(),
80 subscribeConfig: vi.fn(),
81 subscribeLifecycle: vi.fn(),
82 },
83 orchestration: {
84 getSnapshot: vi.fn(),
85 dispatchCommand: vi.fn(),
86 getTurnDiff: vi.fn(),
87 getFullThreadDiff: vi.fn(),
88 replayEvents: vi.fn(),
89 onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) =>
90 registerListener(orchestrationEventListeners, listener),
91 ),
92 },
93};
94
95vi.mock("./wsRpcClient", () => {
96 return {
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 }),
109 __resetWsRpcClientForTests: vi.fn(),
110 };
111});
112
113vi.mock("./contextMenuFallback", () => ({
114 showContextMenuFallback: showContextMenuFallbackMock,
115}));
116
117function emitEvent<T>(listeners: Set<(event: T) => void>, event: T) {
118 for (const listener of listeners) {
119 listener(event);
120 }
121}
122
123function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } {
124 const testGlobal = globalThis as typeof globalThis & {
125 window?: Window & typeof globalThis & { desktopBridge?: unknown };
126 };
127 if (!testGlobal.window) {
128 testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown };
129 }
130 return testGlobal.window;
131}
132
133function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {
134 return {
135 getWsUrl: () => null,
136 getLocalEnvironmentBootstrap: () => null,
137 pickFolder: async () => null,
138 confirm: async () => true,
139 setTheme: async () => undefined,
140 showContextMenu: async () => null,
141 openExternal: async () => true,
142 onMenuAction: () => () => undefined,
143 getUpdateState: async () => {
144 throw new Error("getUpdateState not implemented in test");
145 },
146 checkForUpdate: async () => {
147 throw new Error("checkForUpdate not implemented in test");
148 },
149 downloadUpdate: async () => {
150 throw new Error("downloadUpdate not implemented in test");
151 },
152 installUpdate: async () => {
153 throw new Error("installUpdate not implemented in test");
154 },
155 onUpdateState: () => () => undefined,
156 ...overrides,
157 };
158}
159
160const defaultProviders: ReadonlyArray<ServerProvider> = [
161 {
162 provider: "codex",
163 enabled: true,
164 installed: true,
165 version: "0.116.0",
166 status: "ready",
167 auth: { status: "authenticated" },
168 checkedAt: "2026-01-01T00:00:00.000Z",
169 models: [],
170 },
171];
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
186const baseServerConfig: ServerConfig = {
187 environment: baseEnvironment,
188 cwd: "/tmp/workspace",
189 keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
190 keybindings: [],
191 issues: [],
192 providers: defaultProviders,
193 availableEditors: ["cursor"],
194 observability: {
195 logsDirectoryPath: "/tmp/workspace/.config/logs",
196 localTracingEnabled: true,
197 otlpTracesEnabled: false,
198 otlpMetricsEnabled: false,
199 },
200 settings: DEFAULT_SERVER_SETTINGS,
201};
202
203const baseGitStatus: GitStatusResult = {
204 isRepo: true,
205 hasOriginRemote: true,
206 isDefaultBranch: false,
207 branch: "feature/streamed",
208 hasWorkingTreeChanges: false,
209 workingTree: { files: [], insertions: 0, deletions: 0 },
210 hasUpstream: true,
211 aheadCount: 0,
212 behindCount: 0,
213 pr: null,
214};
215
216beforeEach(() => {
217 vi.resetModules();
218 vi.clearAllMocks();
219 showContextMenuFallbackMock.mockReset();
220 terminalEventListeners.clear();
221 orchestrationEventListeners.clear();
222 gitStatusListeners.clear();
223 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
224});
225
226afterEach(() => {
227 vi.restoreAllMocks();
228});
229
230describe("wsApi", () => {
231 it("forwards server config fetches directly to the RPC client", async () => {
232 rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig);
233 const { createLocalApi } = await import("./localApi");
234
235 const api = createLocalApi();
236
237 await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig);
238 expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith();
239 expect(rpcClientMock.server.subscribeConfig).not.toHaveBeenCalled();
240 expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled();
241 });
242
243 it("forwards terminal and orchestration stream events", async () => {
244 const { createEnvironmentApi } = await import("./environmentApi");
245
246 const api = createEnvironmentApi(rpcClientMock as never);
247 const onTerminalEvent = vi.fn();
248 const onDomainEvent = vi.fn();
249
250 api.terminal.onEvent(onTerminalEvent);
251 api.orchestration.onDomainEvent(onDomainEvent);
252
253 const terminalEvent = {
254 threadId: "thread-1",
255 terminalId: "terminal-1",
256 createdAt: "2026-02-24T00:00:00.000Z",
257 type: "output",
258 data: "hello",
259 } as const;
260 emitEvent(terminalEventListeners, terminalEvent);
261
262 const orchestrationEvent = {
263 sequence: 1,
264 eventId: EventId.makeUnsafe("event-1"),
265 aggregateKind: "project",
266 aggregateId: ProjectId.makeUnsafe("project-1"),
267 occurredAt: "2026-02-24T00:00:00.000Z",
268 commandId: null,
269 causationEventId: null,
270 correlationId: null,
271 metadata: {},
272 type: "project.created",
273 payload: {
274 projectId: ProjectId.makeUnsafe("project-1"),
275 title: "Project",
276 workspaceRoot: "/tmp/workspace",
277 defaultModelSelection: {
278 provider: "codex",
279 model: "gpt-5-codex",
280 },
281 scripts: [],
282 createdAt: "2026-02-24T00:00:00.000Z",
283 updatedAt: "2026-02-24T00:00:00.000Z",
284 },
285 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>;
286 emitEvent(orchestrationEventListeners, orchestrationEvent);
287
288 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);
289 expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);
290 });
291
292 it("forwards git status stream events", async () => {
293 const { createEnvironmentApi } = await import("./environmentApi");
294
295 const api = createEnvironmentApi(rpcClientMock as never);
296 const onStatus = vi.fn();
297
298 api.git.onStatus({ cwd: "/repo" }, onStatus);
299
300 const gitStatus = baseGitStatus;
301 emitEvent(gitStatusListeners, gitStatus);
302
303 expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined);
304 expect(onStatus).toHaveBeenCalledWith(gitStatus);
305 });
306
307 it("forwards git status refreshes directly to the RPC client", async () => {
308 rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus);
309 const { createEnvironmentApi } = await import("./environmentApi");
310
311 const api = createEnvironmentApi(rpcClientMock as never);
312
313 await api.git.refreshStatus({ cwd: "/repo" });
314
315 expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
316 });
317
318 it("forwards orchestration stream subscription options to the RPC client", async () => {
319 const { createEnvironmentApi } = await import("./environmentApi");
320
321 const api = createEnvironmentApi(rpcClientMock as never);
322 const onDomainEvent = vi.fn();
323 const onResubscribe = vi.fn();
324
325 api.orchestration.onDomainEvent(onDomainEvent, { onResubscribe });
326
327 expect(rpcClientMock.orchestration.onDomainEvent).toHaveBeenCalledWith(onDomainEvent, {
328 onResubscribe,
329 });
330 });
331
332 it("sends orchestration dispatch commands as the direct RPC payload", async () => {
333 rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 });
334 const { createEnvironmentApi } = await import("./environmentApi");
335
336 const api = createEnvironmentApi(rpcClientMock as never);
337 const command = {
338 type: "project.create",
339 commandId: CommandId.makeUnsafe("cmd-1"),
340 projectId: ProjectId.makeUnsafe("project-1"),
341 title: "Project",
342 workspaceRoot: "/tmp/project",
343 defaultModelSelection: {
344 provider: "codex",
345 model: "gpt-5-codex",
346 },
347 createdAt: "2026-02-24T00:00:00.000Z",
348 } as const;
349 await api.orchestration.dispatchCommand(command);
350
351 expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command);
352 });
353
354 it("forwards workspace file writes to the project RPC", async () => {
355 rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" });
356 const { createEnvironmentApi } = await import("./environmentApi");
357
358 const api = createEnvironmentApi(rpcClientMock as never);
359 await api.projects.writeFile({
360 cwd: "/tmp/project",
361 relativePath: "plan.md",
362 contents: "# Plan\n",
363 });
364
365 expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({
366 cwd: "/tmp/project",
367 relativePath: "plan.md",
368 contents: "# Plan\n",
369 });
370 });
371
372 it("forwards full-thread diff requests to the orchestration RPC", async () => {
373 rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" });
374 const { createEnvironmentApi } = await import("./environmentApi");
375
376 const api = createEnvironmentApi(rpcClientMock as never);
377 await api.orchestration.getFullThreadDiff({
378 threadId: ThreadId.makeUnsafe("thread-1"),
379 toTurnCount: 1,
380 });
381
382 expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({
383 threadId: "thread-1",
384 toTurnCount: 1,
385 });
386 });
387
388 it("forwards provider refreshes directly to the RPC client", async () => {
389 const nextProviders: ReadonlyArray<ServerProvider> = [
390 {
391 ...defaultProviders[0]!,
392 checkedAt: "2026-01-03T00:00:00.000Z",
393 },
394 ];
395 rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders });
396 const { createLocalApi } = await import("./localApi");
397
398 const api = createLocalApi();
399
400 await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders });
401 expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();
402 });
403
404 it("forwards server settings updates directly to the RPC client", async () => {
405 const nextSettings = {
406 ...DEFAULT_SERVER_SETTINGS,
407 enableAssistantStreaming: true,
408 };
409 rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings);
410 const { createLocalApi } = await import("./localApi");
411
412 const api = createLocalApi();
413
414 await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual(
415 nextSettings,
416 );
417 expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({
418 enableAssistantStreaming: true,
419 });
420 });
421
422 it("forwards context menu metadata to the desktop bridge", async () => {
423 const showContextMenu = vi.fn().mockResolvedValue("delete");
424 getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu });
425
426 const { createLocalApi } = await import("./localApi");
427 const api = createLocalApi();
428 const items = [{ id: "delete", label: "Delete" }] as const;
429
430 await expect(api.contextMenu.show(items)).resolves.toBe("delete");
431 expect(showContextMenu).toHaveBeenCalledWith(items, undefined);
432 });
433
434 it("falls back to the browser context menu helper when the desktop bridge is missing", async () => {
435 showContextMenuFallbackMock.mockResolvedValue("rename");
436 const { createLocalApi } = await import("./localApi");
437
438 const api = createLocalApi();
439 const items = [{ id: "rename", label: "Rename" }] as const;
440
441 await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename");
442 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });
443 });
444});
apps/web/src/localApi.ts created+94
...@@ -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 created+19
...@@ -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 deleted-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", () => {...@@ -68,7 +68,7 @@ describe("deriveOrchestrationBatchEffects", () => {
68 }),68 }),
69 ]);69 ]);
7070
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", () => {...@@ -106,7 +106,7 @@ describe("deriveOrchestrationBatchEffects", () => {
106 }),106 }),
107 ]);107 ]);
108108
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", () => {...@@ -127,7 +127,7 @@ describe("deriveOrchestrationBatchEffects", () => {
127 }),127 }),
128 ]);128 ]);
129129
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 @@...@@ -1,7 +1,7 @@
1import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts";1import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts";
22
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(...@@ -70,12 +70,12 @@ export function deriveOrchestrationBatchEffects(
70 }70 }
71 }71 }
7272
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(...@@ -86,7 +86,7 @@ export function deriveOrchestrationBatchEffects(
86 }86 }
8787
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'...@@ -14,7 +14,8 @@ import { Route as ChatRouteImport } from './routes/_chat'
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'
1819
19const SettingsRoute = SettingsRouteImport.update({20const SettingsRoute = SettingsRouteImport.update({
20 id: '/settings',21 id: '/settings',
...@@ -40,58 +41,70 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({...@@ -40,58 +41,70 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
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)
4855
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' {...@@ -136,24 +149,33 @@ declare module '@tanstack/react-router' {
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}
148168
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}
153174
154const ChatRouteChildren: ChatRouteChildren = {175const ChatRouteChildren: ChatRouteChildren = {
155 ChatThreadIdRoute: ChatThreadIdRoute,
156 ChatIndexRoute: ChatIndexRoute,176 ChatIndexRoute: ChatIndexRoute,
177 ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute,
178 ChatDraftDraftIdRoute: ChatDraftDraftIdRoute,
157}179}
158180
159const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)181const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
apps/web/src/routes/__root.tsx+319-166
...@@ -1,8 +1,15 @@...@@ -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 {...@@ -10,7 +17,7 @@ import {
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";
1623
...@@ -24,7 +31,7 @@ import {...@@ -24,7 +31,7 @@ import {
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 {...@@ -34,11 +41,15 @@ import {
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";...@@ -48,7 +59,15 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
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";
5271
53export const Route = createRootRouteWithContext<{72export const Route = createRootRouteWithContext<{
54 queryClient: QueryClient;73 queryClient: QueryClient;
...@@ -61,7 +80,7 @@ export const Route = createRootRouteWithContext<{...@@ -61,7 +80,7 @@ export const Route = createRootRouteWithContext<{
61});80});
6281
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(...@@ -201,14 +220,24 @@ function coalesceOrchestrationUiEvents(
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;
203222
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), []);
206234
207 return null;235 return null;
208}236}
209237
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() {...@@ -226,15 +255,22 @@ function EventRouter() {
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();
231264
232 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {265 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {
233 if (!payload) return;266 if (!payload) return;
234267
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() {...@@ -242,7 +278,12 @@ function EventRouter() {
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 );
246287
247 if (readPathname() !== "/") {288 if (readPathname() !== "/") {
248 return;289 return;
...@@ -251,8 +292,11 @@ function EventRouter() {...@@ -251,8 +292,11 @@ function EventRouter() {
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() {...@@ -289,7 +333,7 @@ function EventRouter() {
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() {...@@ -317,40 +361,49 @@ function EventRouter() {
317 );361 );
318362
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;
329378
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 };
355408
356 const queryInvalidationThrottler = new Throttler(409 const queryInvalidationThrottler = new Throttler(
...@@ -371,7 +424,11 @@ function EventRouter() {...@@ -371,7 +424,11 @@ function EventRouter() {
371 },424 },
372 );425 );
373426
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() {...@@ -391,189 +448,284 @@ function EventRouter() {
391 void queryInvalidationThrottler.maybeExecute();448 void queryInvalidationThrottler.maybeExecute();
392 }449 }
393450
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 };
428500
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 }
436506
437 flushPendingDomainEventsScheduled = true;507 const events = pendingDomainEvents.splice(0, pendingDomainEvents.length);
438 queueMicrotask(flushPendingDomainEvents);508 applyEventBatch(events, boundEnvironmentId, recovery);
439 };509 };
440510
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 }
445515
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 }
458544
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 };
469559
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 }
511589
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;
531624
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() {...@@ -581,6 +733,7 @@ function EventRouter() {
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.tsx created+292
...@@ -0,0 +1,292 @@
1import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";
2import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
3
4import ChatView from "../components/ChatView";
5import { threadHasStarted } from "../components/ChatView.logic";
6import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
7import {
8 DiffPanelHeaderSkeleton,
9 DiffPanelLoadingState,
10 DiffPanelShell,
11 type DiffPanelMode,
12} from "../components/DiffPanelShell";
13import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
14import {
15 type DiffRouteSearch,
16 parseDiffRouteSearch,
17 stripDiffSearchParams,
18} from "../diffRouteSearch";
19import { useMediaQuery } from "../hooks/useMediaQuery";
20import { selectEnvironmentState, selectThreadByRef, useStore } from "../store";
21import { createThreadSelectorByRef } from "../storeSelectors";
22import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";
23import { Sheet, SheetPopup } from "../components/ui/sheet";
24import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
25
26const DiffPanel = lazy(() => import("../components/DiffPanel"));
27const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
28const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width";
29const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)";
30const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16;
31const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208;
32
33const DiffPanelSheet = (props: {
34 children: ReactNode;
35 diffOpen: boolean;
36 onCloseDiff: () => void;
37}) => {
38 return (
39 <Sheet
40 open={props.diffOpen}
41 onOpenChange={(open) => {
42 if (!open) {
43 props.onCloseDiff();
44 }
45 }}
46 >
47 <SheetPopup
48 side="right"
49 showCloseButton={false}
50 keepMounted
51 className="w-[min(88vw,820px)] max-w-[820px] p-0"
52 >
53 {props.children}
54 </SheetPopup>
55 </Sheet>
56 );
57};
58
59const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => {
60 return (
61 <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}>
62 <DiffPanelLoadingState label="Loading diff viewer..." />
63 </DiffPanelShell>
64 );
65};
66
67const LazyDiffPanel = (props: { mode: DiffPanelMode }) => {
68 return (
69 <DiffWorkerPoolProvider>
70 <Suspense fallback={<DiffLoadingFallback mode={props.mode} />}>
71 <DiffPanel mode={props.mode} />
72 </Suspense>
73 </DiffWorkerPoolProvider>
74 );
75};
76
77const DiffPanelInlineSidebar = (props: {
78 diffOpen: boolean;
79 onCloseDiff: () => void;
80 onOpenDiff: () => void;
81 renderDiffContent: boolean;
82}) => {
83 const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props;
84 const onOpenChange = useCallback(
85 (open: boolean) => {
86 if (open) {
87 onOpenDiff();
88 return;
89 }
90 onCloseDiff();
91 },
92 [onCloseDiff, onOpenDiff],
93 );
94 const shouldAcceptInlineSidebarWidth = useCallback(
95 ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => {
96 const composerForm = document.querySelector<HTMLElement>("[data-chat-composer-form='true']");
97 if (!composerForm) return true;
98 const composerViewport = composerForm.parentElement;
99 if (!composerViewport) return true;
100 const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width");
101 wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
102
103 const viewportStyle = window.getComputedStyle(composerViewport);
104 const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0;
105 const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0;
106 const viewportContentWidth = Math.max(
107 0,
108 composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight,
109 );
110 const formRect = composerForm.getBoundingClientRect();
111 const composerFooter = composerForm.querySelector<HTMLElement>(
112 "[data-chat-composer-footer='true']",
113 );
114 const composerRightActions = composerForm.querySelector<HTMLElement>(
115 "[data-chat-composer-actions='right']",
116 );
117 const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0;
118 const composerFooterGap = composerFooter
119 ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) ||
120 Number.parseFloat(window.getComputedStyle(composerFooter).gap) ||
121 0
122 : 0;
123 const minimumComposerWidth =
124 COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap;
125 const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5;
126 const overflowsViewport = formRect.width > viewportContentWidth + 0.5;
127 const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth;
128
129 if (previousSidebarWidth.length > 0) {
130 wrapper.style.setProperty("--sidebar-width", previousSidebarWidth);
131 } else {
132 wrapper.style.removeProperty("--sidebar-width");
133 }
134
135 return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth;
136 },
137 [],
138 );
139
140 return (
141 <SidebarProvider
142 defaultOpen={false}
143 open={diffOpen}
144 onOpenChange={onOpenChange}
145 className="w-auto min-h-0 flex-none bg-transparent"
146 style={{ "--sidebar-width": DIFF_INLINE_DEFAULT_WIDTH } as React.CSSProperties}
147 >
148 <Sidebar
149 side="right"
150 collapsible="offcanvas"
151 className="border-l border-border bg-card text-foreground"
152 resizable={{
153 minWidth: DIFF_INLINE_SIDEBAR_MIN_WIDTH,
154 shouldAcceptWidth: shouldAcceptInlineSidebarWidth,
155 storageKey: DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY,
156 }}
157 >
158 {renderDiffContent ? <LazyDiffPanel mode="sidebar" /> : null}
159 <SidebarRail />
160 </Sidebar>
161 </SidebarProvider>
162 );
163};
164
165function ChatThreadRouteView() {
166 const navigate = useNavigate();
167 const threadRef = Route.useParams({
168 select: (params) => resolveThreadRouteRef(params),
169 });
170 const search = Route.useSearch();
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 );
179 const draftThreadExists = useComposerDraftStore((store) =>
180 threadRef ? store.getDraftThreadByRef(threadRef) !== null : false,
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 });
191 const routeThreadExists = threadExists || draftThreadExists;
192 const serverThreadStarted = threadHasStarted(serverThread);
193 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
194 const diffOpen = search.diff === "1";
195 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
196 const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen);
197 const closeDiff = useCallback(() => {
198 if (!threadRef) {
199 return;
200 }
201 void navigate({
202 to: "/$environmentId/$threadId",
203 params: buildThreadRouteParams(threadRef),
204 search: { diff: undefined },
205 });
206 }, [navigate, threadRef]);
207 const openDiff = useCallback(() => {
208 if (!threadRef) {
209 return;
210 }
211 void navigate({
212 to: "/$environmentId/$threadId",
213 params: buildThreadRouteParams(threadRef),
214 search: (previous) => {
215 const rest = stripDiffSearchParams(previous);
216 return { ...rest, diff: "1" };
217 },
218 });
219 }, [navigate, threadRef]);
220
221 useEffect(() => {
222 if (diffOpen) {
223 setHasOpenedDiff(true);
224 }
225 }, [diffOpen]);
226
227 useEffect(() => {
228 if (!threadRef || !bootstrapComplete) {
229 return;
230 }
231
232 if (!routeThreadExists && environmentHasAnyThreads) {
233 void navigate({ to: "/", replace: true });
234 }
235 }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]);
236
237 useEffect(() => {
238 if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) {
239 return;
240 }
241 finalizePromotedDraftThreadByRef(threadRef);
242 }, [draftThread?.promotedTo, serverThreadStarted, threadRef]);
243
244 if (!threadRef || !bootstrapComplete || !routeThreadExists) {
245 return null;
246 }
247
248 const shouldRenderDiffContent = diffOpen || hasOpenedDiff;
249
250 if (!shouldUseDiffSheet) {
251 return (
252 <>
253 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
254 <ChatView
255 environmentId={threadRef.environmentId}
256 threadId={threadRef.threadId}
257 routeKind="server"
258 />
259 </SidebarInset>
260 <DiffPanelInlineSidebar
261 diffOpen={diffOpen}
262 onCloseDiff={closeDiff}
263 onOpenDiff={openDiff}
264 renderDiffContent={shouldRenderDiffContent}
265 />
266 </>
267 );
268 }
269
270 return (
271 <>
272 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
273 <ChatView
274 environmentId={threadRef.environmentId}
275 threadId={threadRef.threadId}
276 routeKind="server"
277 />
278 </SidebarInset>
279 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
280 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}
281 </DiffPanelSheet>
282 </>
283 );
284}
285
286export const Route = createFileRoute("/_chat/$environmentId/$threadId")({
287 validateSearch: (search) => parseDiffRouteSearch(search),
288 search: {
289 middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])],
290 },
291 component: ChatThreadRouteView,
292});
apps/web/src/routes/_chat.$threadId.tsx deleted-255
...@@ -1,255 +0,0 @@
1import { ThreadId } from "@t3tools/contracts";
2import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";
3import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react";
4
5import ChatView from "../components/ChatView";
6import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
7import {
8 DiffPanelHeaderSkeleton,
9 DiffPanelLoadingState,
10 DiffPanelShell,
11 type DiffPanelMode,
12} from "../components/DiffPanelShell";
13import { useComposerDraftStore } from "../composerDraftStore";
14import {
15 type DiffRouteSearch,
16 parseDiffRouteSearch,
17 stripDiffSearchParams,
18} from "../diffRouteSearch";
19import { useMediaQuery } from "../hooks/useMediaQuery";
20import { useStore } from "../store";
21import { Sheet, SheetPopup } from "../components/ui/sheet";
22import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
23
24const DiffPanel = lazy(() => import("../components/DiffPanel"));
25const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
26const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width";
27const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)";
28const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16;
29const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208;
30
31const DiffPanelSheet = (props: {
32 children: ReactNode;
33 diffOpen: boolean;
34 onCloseDiff: () => void;
35}) => {
36 return (
37 <Sheet
38 open={props.diffOpen}
39 onOpenChange={(open) => {
40 if (!open) {
41 props.onCloseDiff();
42 }
43 }}
44 >
45 <SheetPopup
46 side="right"
47 showCloseButton={false}
48 keepMounted
49 className="w-[min(88vw,820px)] max-w-[820px] p-0"
50 >
51 {props.children}
52 </SheetPopup>
53 </Sheet>
54 );
55};
56
57const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => {
58 return (
59 <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}>
60 <DiffPanelLoadingState label="Loading diff viewer..." />
61 </DiffPanelShell>
62 );
63};
64
65const LazyDiffPanel = (props: { mode: DiffPanelMode }) => {
66 return (
67 <DiffWorkerPoolProvider>
68 <Suspense fallback={<DiffLoadingFallback mode={props.mode} />}>
69 <DiffPanel mode={props.mode} />
70 </Suspense>
71 </DiffWorkerPoolProvider>
72 );
73};
74
75const DiffPanelInlineSidebar = (props: {
76 diffOpen: boolean;
77 onCloseDiff: () => void;
78 onOpenDiff: () => void;
79 renderDiffContent: boolean;
80}) => {
81 const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props;
82 const onOpenChange = useCallback(
83 (open: boolean) => {
84 if (open) {
85 onOpenDiff();
86 return;
87 }
88 onCloseDiff();
89 },
90 [onCloseDiff, onOpenDiff],
91 );
92 const shouldAcceptInlineSidebarWidth = useCallback(
93 ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => {
94 const composerForm = document.querySelector<HTMLElement>("[data-chat-composer-form='true']");
95 if (!composerForm) return true;
96 const composerViewport = composerForm.parentElement;
97 if (!composerViewport) return true;
98 const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width");
99 wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
100
101 const viewportStyle = window.getComputedStyle(composerViewport);
102 const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0;
103 const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0;
104 const viewportContentWidth = Math.max(
105 0,
106 composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight,
107 );
108 const formRect = composerForm.getBoundingClientRect();
109 const composerFooter = composerForm.querySelector<HTMLElement>(
110 "[data-chat-composer-footer='true']",
111 );
112 const composerRightActions = composerForm.querySelector<HTMLElement>(
113 "[data-chat-composer-actions='right']",
114 );
115 const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0;
116 const composerFooterGap = composerFooter
117 ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) ||
118 Number.parseFloat(window.getComputedStyle(composerFooter).gap) ||
119 0
120 : 0;
121 const minimumComposerWidth =
122 COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap;
123 const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5;
124 const overflowsViewport = formRect.width > viewportContentWidth + 0.5;
125 const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth;
126
127 if (previousSidebarWidth.length > 0) {
128 wrapper.style.setProperty("--sidebar-width", previousSidebarWidth);
129 } else {
130 wrapper.style.removeProperty("--sidebar-width");
131 }
132
133 return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth;
134 },
135 [],
136 );
137
138 return (
139 <SidebarProvider
140 defaultOpen={false}
141 open={diffOpen}
142 onOpenChange={onOpenChange}
143 className="w-auto min-h-0 flex-none bg-transparent"
144 style={{ "--sidebar-width": DIFF_INLINE_DEFAULT_WIDTH } as React.CSSProperties}
145 >
146 <Sidebar
147 side="right"
148 collapsible="offcanvas"
149 className="border-l border-border bg-card text-foreground"
150 resizable={{
151 minWidth: DIFF_INLINE_SIDEBAR_MIN_WIDTH,
152 shouldAcceptWidth: shouldAcceptInlineSidebarWidth,
153 storageKey: DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY,
154 }}
155 >
156 {renderDiffContent ? <LazyDiffPanel mode="sidebar" /> : null}
157 <SidebarRail />
158 </Sidebar>
159 </SidebarProvider>
160 );
161};
162
163function ChatThreadRouteView() {
164 const bootstrapComplete = useStore((store) => store.bootstrapComplete);
165 const navigate = useNavigate();
166 const threadId = Route.useParams({
167 select: (params) => ThreadId.makeUnsafe(params.threadId),
168 });
169 const search = Route.useSearch();
170 const threadExists = useStore((store) => store.threadShellById[threadId] !== undefined);
171 const draftThreadExists = useComposerDraftStore((store) =>
172 Object.hasOwn(store.draftThreadsByThreadId, threadId),
173 );
174 const routeThreadExists = threadExists || draftThreadExists;
175 const diffOpen = search.diff === "1";
176 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);
180 const closeDiff = useCallback(() => {
181 void navigate({
182 to: "/$threadId",
183 params: { threadId },
184 search: { diff: undefined },
185 });
186 }, [navigate, threadId]);
187 const openDiff = useCallback(() => {
188 void navigate({
189 to: "/$threadId",
190 params: { threadId },
191 search: (previous) => {
192 const rest = stripDiffSearchParams(previous);
193 return { ...rest, diff: "1" };
194 },
195 });
196 }, [navigate, threadId]);
197
198 useEffect(() => {
199 if (diffOpen) {
200 setHasOpenedDiff(true);
201 }
202 }, [diffOpen]);
203
204 useEffect(() => {
205 if (!bootstrapComplete) {
206 return;
207 }
208
209 if (!routeThreadExists) {
210 void navigate({ to: "/", replace: true });
211 return;
212 }
213 }, [bootstrapComplete, navigate, routeThreadExists, threadId]);
214
215 if (!bootstrapComplete || !routeThreadExists) {
216 return null;
217 }
218
219 const shouldRenderDiffContent = diffOpen || hasOpenedDiff;
220
221 if (!shouldUseDiffSheet) {
222 return (
223 <>
224 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
225 <ChatView threadId={threadId} />
226 </SidebarInset>
227 <DiffPanelInlineSidebar
228 diffOpen={diffOpen}
229 onCloseDiff={closeDiff}
230 onOpenDiff={openDiff}
231 renderDiffContent={shouldRenderDiffContent}
232 />
233 </>
234 );
235 }
236
237 return (
238 <>
239 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
240 <ChatView threadId={threadId} />
241 </SidebarInset>
242 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
243 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}
244 </DiffPanelSheet>
245 </>
246 );
247}
248
249export const Route = createFileRoute("/_chat/$threadId")({
250 validateSearch: (search) => parseDiffRouteSearch(search),
251 search: {
252 middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])],
253 },
254 component: ChatThreadRouteView,
255});
apps/web/src/routes/_chat.draft.$draftId.tsx created+86
...@@ -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 @@...@@ -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";
34
...@@ -12,13 +13,13 @@ import { useServerKeybindings } from "~/rpc/serverState";...@@ -12,13 +13,13 @@ import { useServerKeybindings } from "~/rpc/serverState";
1213
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() {...@@ -27,14 +28,18 @@ function ChatRouteGlobalShortcuts() {
27 const onWindowKeyDown = (event: KeyboardEvent) => {28 const onWindowKeyDown = (event: KeyboardEvent) => {
28 if (event.defaultPrevented) return;29 if (event.defaultPrevented) return;
2930
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 }
3536
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;
3843
39 const command = resolveShortcutCommand(event, keybindings, {44 const command = resolveShortcutCommand(event, keybindings, {
40 context: {45 context: {
...@@ -46,7 +51,7 @@ function ChatRouteGlobalShortcuts() {...@@ -46,7 +51,7 @@ function ChatRouteGlobalShortcuts() {
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() {...@@ -57,7 +62,7 @@ function ChatRouteGlobalShortcuts() {
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() {...@@ -77,8 +82,9 @@ function ChatRouteGlobalShortcuts() {
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
...@@ -1,5 +1,6 @@...@@ -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> = [...@@ -50,7 +51,21 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [
50 },51 },
51];52];
5253
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", () => {...@@ -193,6 +208,7 @@ describe("serverState", () => {
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", () => {...@@ -201,6 +217,7 @@ describe("serverState", () => {
201 });217 });
202218
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", () => {...@@ -210,6 +227,7 @@ describe("serverState", () => {
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 @@...@@ -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";...@@ -14,16 +15,49 @@ import { describe, expect, it } from "vitest";
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";
2326
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 {...@@ -52,6 +86,7 @@ function makeState(thread: Thread): AppState {
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 {...@@ -62,10 +97,10 @@ function makeState(thread: Thread): AppState {
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 {...@@ -75,6 +110,7 @@ function makeState(thread: Thread): AppState {
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 {...@@ -106,7 +142,7 @@ function makeState(thread: Thread): AppState {
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 {...@@ -114,7 +150,7 @@ function makeState(thread: Thread): AppState {
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 {...@@ -122,7 +158,7 @@ function makeState(thread: Thread): AppState {
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 {...@@ -130,15 +166,16 @@ function makeState(thread: Thread): AppState {
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}
139176
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 {...@@ -156,16 +193,20 @@ function makeEmptyState(overrides: Partial<AppState> = {}): AppState {
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}
162203
163function projectsOf(state: AppState) {204function projectsOf(state: AppState) {
164 return selectProjects(state);205 return selectProjectsAcrossEnvironments(state);
165}206}
166207
167function threadsOf(state: AppState) {208function threadsOf(state: AppState) {
168 return selectThreads(state);209 return selectThreadsAcrossEnvironments(state);
169}210}
170211
171function makeEvent<T extends OrchestrationEvent["type"]>(212function makeEvent<T extends OrchestrationEvent["type"]>(
...@@ -266,14 +307,20 @@ function makeReadModelProject(...@@ -266,14 +307,20 @@ function makeReadModelProject(
266307
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 );
273316
274 const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({})));317 const next = syncServerReadModel(
318 initialState,
319 makeReadModel(makeReadModelThread({})),
320 localEnvironmentId,
321 );
275322
276 expect(next.bootstrapComplete).toBe(true);323 expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true);
277 });324 });
278325
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", () => {...@@ -287,7 +334,7 @@ describe("store read model sync", () => {
287 }),334 }),
288 );335 );
289336
290 const next = syncServerReadModel(initialState, readModel);337 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
291338
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", () => {...@@ -312,7 +359,7 @@ describe("store read model sync", () => {
312 }),359 }),
313 );360 );
314361
315 const next = syncServerReadModel(initialState, readModel);362 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
316363
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", () => {...@@ -325,7 +372,7 @@ describe("store read model sync", () => {
325 }),372 }),
326 );373 );
327374
328 const next = syncServerReadModel(initialState, readModel);375 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
329376
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", () => {...@@ -341,6 +388,7 @@ describe("store read model sync", () => {
341 archivedAt,388 archivedAt,
342 }),389 }),
343 ),390 ),
391 localEnvironmentId,
344 );392 );
345393
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", () => {...@@ -355,6 +403,7 @@ describe("store read model sync", () => {
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", () => {...@@ -367,6 +416,7 @@ describe("store read model sync", () => {
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", () => {...@@ -402,7 +452,7 @@ describe("store read model sync", () => {
402 threads: [],452 threads: [],
403 };453 };
404454
405 const next = syncServerReadModel(initialState, readModel);455 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
406456
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", () => {...@@ -410,10 +460,9 @@ describe("store read model sync", () => {
410460
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 });
417466
418 const next = applyOrchestrationEvent(467 const next = applyOrchestrationEvent(
419 state,468 state,
...@@ -422,30 +471,10 @@ describe("incremental orchestration updates", () => {...@@ -422,30 +471,10 @@ describe("incremental orchestration updates", () => {
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 );
426476
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 });
450479
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", () => {...@@ -458,6 +487,7 @@ describe("incremental orchestration updates", () => {
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", () => {...@@ -465,6 +495,7 @@ describe("incremental orchestration updates", () => {
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 );
469500
470 expect(nextAfterProjectDelete).toBe(state);501 expect(nextAfterProjectDelete).toBe(state);
...@@ -479,6 +510,7 @@ describe("incremental orchestration updates", () => {...@@ -479,6 +510,7 @@ describe("incremental orchestration updates", () => {
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", () => {...@@ -506,15 +538,18 @@ describe("incremental orchestration updates", () => {
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 );
510543
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 });
519554
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", () => {...@@ -525,12 +560,12 @@ describe("incremental orchestration updates", () => {
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", () => {...@@ -543,6 +578,7 @@ describe("incremental orchestration updates", () => {
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", () => {...@@ -554,7 +590,7 @@ describe("incremental orchestration updates", () => {
554 scripts: [],590 scripts: [],
555 },591 },
556 },592 },
557 };593 });
558594
559 const next = applyOrchestrationEvent(595 const next = applyOrchestrationEvent(
560 state,596 state,
...@@ -573,12 +609,15 @@ describe("incremental orchestration updates", () => {...@@ -573,12 +609,15 @@ describe("incremental orchestration updates", () => {
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 );
577614
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 });
583622
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", () => {...@@ -597,13 +636,15 @@ describe("incremental orchestration updates", () => {
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", () => {...@@ -619,54 +660,54 @@ describe("incremental orchestration updates", () => {
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 });
670711
671 const next = applyOrchestrationEvent(712 const next = applyOrchestrationEvent(
672 state,713 state,
...@@ -680,14 +721,25 @@ describe("incremental orchestration updates", () => {...@@ -680,14 +721,25 @@ describe("incremental orchestration updates", () => {
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 );
684726
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 });
692744
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", () => {...@@ -703,38 +755,42 @@ describe("incremental orchestration updates", () => {
703 });755 });
704 const state = makeState(thread);756 const state = makeState(thread);
705757
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 );
738794
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", () => {...@@ -767,6 +823,7 @@ describe("incremental orchestration updates", () => {
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 );
771828
772 expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1);829 expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1);
...@@ -811,6 +868,7 @@ describe("incremental orchestration updates", () => {...@@ -811,6 +868,7 @@ describe("incremental orchestration updates", () => {
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 );
815873
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", () => {...@@ -920,6 +978,7 @@ describe("incremental orchestration updates", () => {
920 threadId: ThreadId.makeUnsafe("thread-1"),978 threadId: ThreadId.makeUnsafe("thread-1"),
921 turnCount: 1,979 turnCount: 1,
922 }),980 }),
981 localEnvironmentId,
923 );982 );
924983
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", () => {...@@ -978,6 +1037,7 @@ describe("incremental orchestration updates", () => {
978 threadId: thread.id,1037 threadId: thread.id,
979 turnCount: 1,1038 turnCount: 1,
980 }),1039 }),
1040 localEnvironmentId,
981 );1041 );
9821042
983 expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined();1043 expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined();
...@@ -996,6 +1056,7 @@ describe("incremental orchestration updates", () => {...@@ -996,6 +1056,7 @@ describe("incremental orchestration updates", () => {
996 updatedAt: "2026-02-27T00:00:04.000Z",1056 updatedAt: "2026-02-27T00:00:04.000Z",
997 },1057 },
998 }),1058 }),
1059 localEnvironmentId,
999 );1060 );
10001061
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 @@...@@ -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 {...@@ -11,6 +12,8 @@ import {
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 {...@@ -35,7 +38,7 @@ import {
35} from "./types";38} from "./types";
36import { sanitizeThreadErrorMessage } from "./rpc/transportError";39import { sanitizeThreadErrorMessage } from "./rpc/transportError";
3740
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 {...@@ -55,7 +58,12 @@ export interface AppState {
55 bootstrapComplete: boolean;58 bootstrapComplete: boolean;
56}59}
5760
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 = {...@@ -75,6 +83,11 @@ const initialState: AppState = {
75 bootstrapComplete: false,83 bootstrapComplete: false,
76};84};
7785
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...@@ -169,11 +182,16 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif
169 };182 };
170}183}
171184
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...@@ -183,9 +201,10 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec
183 };201 };
184}202}
185203
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 {...@@ -211,6 +230,7 @@ function mapThread(thread: OrchestrationThread): Thread {
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 |...@@ -251,6 +271,7 @@ function getLatestUserMessageAt(messages: ReadonlyArray<ChatMessage>): string |
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...@@ -298,6 +319,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b
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): {...@@ -377,7 +399,7 @@ function buildTurnDiffSlice(thread: Thread): {
377 };399 };
378}400}
379401
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[...@@ -390,7 +412,7 @@ function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[
390}412}
391413
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(...@@ -404,7 +426,7 @@ function selectThreadActivities(
404 });426 });
405}427}
406428
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...@@ -416,7 +438,10 @@ function selectThreadProposedPlans(state: AppState, threadId: ThreadId): Propose
416 });438 });
417}439}
418440
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...@@ -428,7 +453,7 @@ function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): Tur
428 });453 });
429}454}
430455
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 {...@@ -446,21 +471,25 @@ function getThread(state: AppState, threadId: ThreadId): Thread | undefined {
446 };471 };
447}472}
448473
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}
455480
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}
462487
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?:...@@ -613,7 +642,7 @@ function writeThreadState(state: AppState, nextThread: Thread, previousThread?:
613 return nextState;642 return nextState;
614}643}
615644
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 {...@@ -887,10 +916,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string {
887}916}
888917
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(...@@ -904,7 +933,7 @@ function updateThreadState(
904933
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(...@@ -916,7 +945,7 @@ function buildProjectState(
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(...@@ -989,11 +1018,48 @@ function buildThreadState(
989 };1018 };
990}1019}
9911020
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...@@ -1002,19 +1068,43 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
1002 };1068 };
1003}1069}
10041070
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...@@ -1060,6 +1150,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
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...@@ -1095,26 +1188,29 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
10951188
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 }
11201216
...@@ -1481,70 +1577,218 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -1481,70 +1577,218 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
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(
14901587 (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;
15091592
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}
15161658
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}
15341769
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}
15421781
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 @@...@@ -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>(...@@ -30,54 +36,61 @@ function collectByIds<TKey extends string, TValue>(
30 });36 });
31}37}
3238
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) =>
43 ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined;
37}44}
3845
39export function createSidebarThreadSummarySelector(46export function createSidebarThreadSummarySelectorByRef(
40 threadId: ThreadId | null | undefined,47 ref: ScopedThreadRef | null | undefined,
41): (state: AppState) => SidebarThreadSummary | undefined {48): (state: AppState) => SidebarThreadSummary | undefined {
42 return (state) => (threadId ? state.sidebarThreadSummaryById[threadId] : undefined);49 return (state) =>
50 ref
51 ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId]
52 : undefined;
43}53}
4454
45export function createThreadSelector(55function createScopedThreadSelector(
46 threadId: ThreadId | null | undefined,56 resolveRef: (state: AppState) => ScopedThreadRef | null | undefined,
47): (state: AppState) => Thread | undefined {57): (state: AppState) => Thread | undefined {
48 let previousShell: AppState["threadShellById"][ThreadId] | undefined;58 let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined;
49 let previousSession: ThreadSession | null | undefined;59 let previousSession: ThreadSession | null | undefined;
50 let previousTurnState: ThreadTurnState | undefined;60 let previousTurnState: ThreadTurnState | undefined;
51 let previousMessageIds: MessageId[] | undefined;61 let previousMessageIds: MessageId[] | undefined;
52 let previousMessagesById: AppState["messageByThreadId"][ThreadId] | undefined;62 let previousMessagesById: EnvironmentState["messageByThreadId"][ThreadId] | undefined;
53 let previousActivityIds: string[] | undefined;63 let previousActivityIds: string[] | undefined;
54 let previousActivitiesById: AppState["activityByThreadId"][ThreadId] | undefined;64 let previousActivitiesById: EnvironmentState["activityByThreadId"][ThreadId] | undefined;
55 let previousProposedPlanIds: string[] | undefined;65 let previousProposedPlanIds: string[] | undefined;
56 let previousProposedPlansById: AppState["proposedPlanByThreadId"][ThreadId] | undefined;66 let previousProposedPlansById: EnvironmentState["proposedPlanByThreadId"][ThreadId] | undefined;
57 let previousTurnDiffIds: TurnId[] | undefined;67 let previousTurnDiffIds: TurnId[] | undefined;
58 let previousTurnDiffsById: AppState["turnDiffSummaryByThreadId"][ThreadId] | undefined;68 let previousTurnDiffsById: EnvironmentState["turnDiffSummaryByThreadId"][ThreadId] | undefined;
59 let previousThread: Thread | undefined;69 let previousThread: Thread | undefined;
6070
61 return (state) => {71 return (state) => {
62 if (!threadId) {72 const ref = resolveRef(state);
73 if (!ref) {
63 return undefined;74 return undefined;
64 }75 }
6576
66 const shell = state.threadShellById[threadId];77 const environmentState = selectEnvironmentState(state, ref.environmentId);
78 const threadId = ref.threadId;
79 const shell = environmentState.threadShellById[threadId];
67 if (!shell) {80 if (!shell) {
68 return undefined;81 return undefined;
69 }82 }
7083
71 const session = state.threadSessionById[threadId] ?? null;84 const session = environmentState.threadSessionById[threadId] ?? null;
72 const turnState = state.threadTurnStateById[threadId];85 const turnState = environmentState.threadTurnStateById[threadId];
73 const messageIds = state.messageIdsByThreadId[threadId];86 const messageIds = environmentState.messageIdsByThreadId[threadId];
74 const messageById = state.messageByThreadId[threadId];87 const messageById = environmentState.messageByThreadId[threadId];
75 const activityIds = state.activityIdsByThreadId[threadId];88 const activityIds = environmentState.activityIdsByThreadId[threadId];
76 const activityById = state.activityByThreadId[threadId];89 const activityById = environmentState.activityByThreadId[threadId];
77 const proposedPlanIds = state.proposedPlanIdsByThreadId[threadId];90 const proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId];
78 const proposedPlanById = state.proposedPlanByThreadId[threadId];91 const proposedPlanById = environmentState.proposedPlanByThreadId[threadId];
79 const turnDiffIds = state.turnDiffIdsByThreadId[threadId];92 const turnDiffIds = environmentState.turnDiffIdsByThreadId[threadId];
80 const turnDiffById = state.turnDiffSummaryByThreadId[threadId];93 const turnDiffById = environmentState.turnDiffSummaryByThreadId[threadId];
8194
82 if (95 if (
83 previousThread &&96 previousThread &&
...@@ -144,3 +157,31 @@ export function createThreadSelector(...@@ -144,3 +157,31 @@ export function createThreadSelector(
144 return previousThread;157 return previousThread;
145 };158 };
146}159}
160
161export function createThreadSelectorByRef(
162 ref: ScopedThreadRef | null | undefined,
163): (state: AppState) => Thread | undefined {
164 return createScopedThreadSelector(() => ref);
165}
166
167export function createThreadSelectorAcrossEnvironments(
168 threadId: ThreadId | null | undefined,
169): (state: AppState) => Thread | undefined {
170 return createScopedThreadSelector((state) => {
171 if (!threadId) {
172 return undefined;
173 }
174
175 for (const [environmentId, environmentState] of Object.entries(
176 state.environmentStateById,
177 ) as Array<[ScopedThreadRef["environmentId"], EnvironmentState]>) {
178 if (environmentState.threadShellById[threadId]) {
179 return {
180 environmentId,
181 threadId,
182 };
183 }
184 }
185 return undefined;
186 });
187}
apps/web/src/terminalStateStore.test.ts+130-75
...@@ -1,13 +1,17 @@...@@ -1,13 +1,17 @@
1import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime";
1import { ThreadId, type TerminalEvent } from "@t3tools/contracts";2import { ThreadId, type TerminalEvent } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it } from "vitest";3import { beforeEach, describe, expect, it } from "vitest";
34
4import {5import {
6 migratePersistedTerminalStateStoreState,
5 selectTerminalEventEntries,7 selectTerminalEventEntries,
6 selectThreadTerminalState,8 selectThreadTerminalState,
7 useTerminalStateStore,9 useTerminalStateStore,
8} from "./terminalStateStore";10} from "./terminalStateStore";
911
10const THREAD_ID = ThreadId.makeUnsafe("thread-1");12const THREAD_ID = ThreadId.makeUnsafe("thread-1");
13const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID);
14const OTHER_THREAD_REF = scopeThreadRef("environment-b" as never, THREAD_ID);
1115
12function makeTerminalEvent(16function makeTerminalEvent(
13 type: TerminalEvent["type"],17 type: TerminalEvent["type"],
...@@ -56,8 +60,8 @@ describe("terminalStateStore actions", () => {...@@ -56,8 +60,8 @@ describe("terminalStateStore actions", () => {
56 beforeEach(() => {60 beforeEach(() => {
57 useTerminalStateStore.persist.clearStorage();61 useTerminalStateStore.persist.clearStorage();
58 useTerminalStateStore.setState({62 useTerminalStateStore.setState({
59 terminalStateByThreadId: {},63 terminalStateByThreadKey: {},
60 terminalLaunchContextByThreadId: {},64 terminalLaunchContextByThreadKey: {},
61 terminalEventEntriesByKey: {},65 terminalEventEntriesByKey: {},
62 nextTerminalEventId: 1,66 nextTerminalEventId: 1,
63 });67 });
...@@ -65,8 +69,8 @@ describe("terminalStateStore actions", () => {...@@ -65,8 +69,8 @@ describe("terminalStateStore actions", () => {
6569
66 it("returns a closed default terminal state for unknown threads", () => {70 it("returns a closed default terminal state for unknown threads", () => {
67 const terminalState = selectThreadTerminalState(71 const terminalState = selectThreadTerminalState(
68 useTerminalStateStore.getState().terminalStateByThreadId,72 useTerminalStateStore.getState().terminalStateByThreadKey,
69 THREAD_ID,73 THREAD_REF,
70 );74 );
71 expect(terminalState).toEqual({75 expect(terminalState).toEqual({
72 terminalOpen: false,76 terminalOpen: false,
...@@ -81,12 +85,12 @@ describe("terminalStateStore actions", () => {...@@ -81,12 +85,12 @@ describe("terminalStateStore actions", () => {
8185
82 it("opens and splits terminals into the active group", () => {86 it("opens and splits terminals into the active group", () => {
83 const store = useTerminalStateStore.getState();87 const store = useTerminalStateStore.getState();
84 store.setTerminalOpen(THREAD_ID, true);88 store.setTerminalOpen(THREAD_REF, true);
85 store.splitTerminal(THREAD_ID, "terminal-2");89 store.splitTerminal(THREAD_REF, "terminal-2");
8690
87 const terminalState = selectThreadTerminalState(91 const terminalState = selectThreadTerminalState(
88 useTerminalStateStore.getState().terminalStateByThreadId,92 useTerminalStateStore.getState().terminalStateByThreadKey,
89 THREAD_ID,93 THREAD_REF,
90 );94 );
91 expect(terminalState.terminalOpen).toBe(true);95 expect(terminalState.terminalOpen).toBe(true);
92 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);96 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
...@@ -98,14 +102,14 @@ describe("terminalStateStore actions", () => {...@@ -98,14 +102,14 @@ describe("terminalStateStore actions", () => {
98102
99 it("caps splits at four terminals per group", () => {103 it("caps splits at four terminals per group", () => {
100 const store = useTerminalStateStore.getState();104 const store = useTerminalStateStore.getState();
101 store.splitTerminal(THREAD_ID, "terminal-2");105 store.splitTerminal(THREAD_REF, "terminal-2");
102 store.splitTerminal(THREAD_ID, "terminal-3");106 store.splitTerminal(THREAD_REF, "terminal-3");
103 store.splitTerminal(THREAD_ID, "terminal-4");107 store.splitTerminal(THREAD_REF, "terminal-4");
104 store.splitTerminal(THREAD_ID, "terminal-5");108 store.splitTerminal(THREAD_REF, "terminal-5");
105109
106 const terminalState = selectThreadTerminalState(110 const terminalState = selectThreadTerminalState(
107 useTerminalStateStore.getState().terminalStateByThreadId,111 useTerminalStateStore.getState().terminalStateByThreadKey,
108 THREAD_ID,112 THREAD_REF,
109 );113 );
110 expect(terminalState.terminalIds).toEqual([114 expect(terminalState.terminalIds).toEqual([
111 "default",115 "default",
...@@ -119,11 +123,11 @@ describe("terminalStateStore actions", () => {...@@ -119,11 +123,11 @@ describe("terminalStateStore actions", () => {
119 });123 });
120124
121 it("creates new terminals in a separate group", () => {125 it("creates new terminals in a separate group", () => {
122 useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2");126 useTerminalStateStore.getState().newTerminal(THREAD_REF, "terminal-2");
123127
124 const terminalState = selectThreadTerminalState(128 const terminalState = selectThreadTerminalState(
125 useTerminalStateStore.getState().terminalStateByThreadId,129 useTerminalStateStore.getState().terminalStateByThreadKey,
126 THREAD_ID,130 THREAD_REF,
127 );131 );
128 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);132 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
129 expect(terminalState.activeTerminalId).toBe("terminal-2");133 expect(terminalState.activeTerminalId).toBe("terminal-2");
...@@ -136,11 +140,11 @@ describe("terminalStateStore actions", () => {...@@ -136,11 +140,11 @@ describe("terminalStateStore actions", () => {
136140
137 it("ensures unknown server terminals are registered, opened, and activated", () => {141 it("ensures unknown server terminals are registered, opened, and activated", () => {
138 const store = useTerminalStateStore.getState();142 const store = useTerminalStateStore.getState();
139 store.ensureTerminal(THREAD_ID, "setup-setup", { open: true, active: true });143 store.ensureTerminal(THREAD_REF, "setup-setup", { open: true, active: true });
140144
141 const terminalState = selectThreadTerminalState(145 const terminalState = selectThreadTerminalState(
142 useTerminalStateStore.getState().terminalStateByThreadId,146 useTerminalStateStore.getState().terminalStateByThreadKey,
143 THREAD_ID,147 THREAD_REF,
144 );148 );
145 expect(terminalState.terminalOpen).toBe(true);149 expect(terminalState.terminalOpen).toBe(true);
146 expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]);150 expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]);
...@@ -151,69 +155,111 @@ describe("terminalStateStore actions", () => {...@@ -151,69 +155,111 @@ describe("terminalStateStore actions", () => {
151 ]);155 ]);
152 });156 });
153157
154 it("allows unlimited groups while keeping each group capped at four terminals", () => {158 it("keeps state isolated per environment when raw thread ids collide", () => {
155 const store = useTerminalStateStore.getState();159 const store = useTerminalStateStore.getState();
156 store.splitTerminal(THREAD_ID, "terminal-2");160 store.setTerminalOpen(THREAD_REF, true);
157 store.splitTerminal(THREAD_ID, "terminal-3");161 store.newTerminal(OTHER_THREAD_REF, "env-b-terminal");
158 store.splitTerminal(THREAD_ID, "terminal-4");
159 store.newTerminal(THREAD_ID, "terminal-5");
160 store.newTerminal(THREAD_ID, "terminal-6");
161162
162 const terminalState = selectThreadTerminalState(163 expect(
163 useTerminalStateStore.getState().terminalStateByThreadId,164 selectThreadTerminalState(
164 THREAD_ID,165 useTerminalStateStore.getState().terminalStateByThreadKey,
166 THREAD_REF,
167 ).terminalOpen,
168 ).toBe(true);
169 expect(
170 selectThreadTerminalState(
171 useTerminalStateStore.getState().terminalStateByThreadKey,
172 OTHER_THREAD_REF,
173 ).terminalIds,
174 ).toEqual(["default", "env-b-terminal"]);
175 });
176
177 it("migrates v1 persisted terminal state using the stored version", () => {
178 const migrated = migratePersistedTerminalStateStoreState(
179 {
180 terminalStateByThreadKey: {
181 [scopedThreadKey(THREAD_REF)]: {
182 terminalOpen: true,
183 terminalHeight: 320,
184 terminalIds: ["default"],
185 runningTerminalIds: [],
186 activeTerminalId: "default",
187 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
188 activeTerminalGroupId: "group-default",
189 },
190 "legacy-thread-id": {
191 terminalOpen: true,
192 terminalHeight: 320,
193 terminalIds: ["default"],
194 runningTerminalIds: [],
195 activeTerminalId: "default",
196 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
197 activeTerminalGroupId: "group-default",
198 },
199 },
200 },
201 1,
165 );202 );
166 expect(terminalState.terminalIds).toEqual([203
167 "default",204 expect(migrated).toEqual({
168 "terminal-2",205 terminalStateByThreadKey: {
169 "terminal-3",206 [scopedThreadKey(THREAD_REF)]: {
170 "terminal-4",207 terminalOpen: true,
171 "terminal-5",208 terminalHeight: 320,
172 "terminal-6",209 terminalIds: ["default"],
173 ]);210 runningTerminalIds: [],
174 expect(terminalState.terminalGroups).toEqual([211 activeTerminalId: "default",
175 { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] },212 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
176 { id: "group-terminal-5", terminalIds: ["terminal-5"] },213 activeTerminalGroupId: "group-default",
177 { id: "group-terminal-6", terminalIds: ["terminal-6"] },214 },
178 ]);215 },
216 });
179 });217 });
180218
181 it("tracks and clears terminal subprocess activity", () => {219 it("tracks and clears terminal subprocess activity", () => {
182 const store = useTerminalStateStore.getState();220 const store = useTerminalStateStore.getState();
183 store.splitTerminal(THREAD_ID, "terminal-2");221 store.splitTerminal(THREAD_REF, "terminal-2");
184 store.setTerminalActivity(THREAD_ID, "terminal-2", true);222 store.setTerminalActivity(THREAD_REF, "terminal-2", true);
185 expect(223 expect(
186 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)224 selectThreadTerminalState(
187 .runningTerminalIds,225 useTerminalStateStore.getState().terminalStateByThreadKey,
226 THREAD_REF,
227 ).runningTerminalIds,
188 ).toEqual(["terminal-2"]);228 ).toEqual(["terminal-2"]);
189229
190 store.setTerminalActivity(THREAD_ID, "terminal-2", false);230 store.setTerminalActivity(THREAD_REF, "terminal-2", false);
191 expect(231 expect(
192 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)232 selectThreadTerminalState(
193 .runningTerminalIds,233 useTerminalStateStore.getState().terminalStateByThreadKey,
234 THREAD_REF,
235 ).runningTerminalIds,
194 ).toEqual([]);236 ).toEqual([]);
195 });237 });
196238
197 it("resets to default and clears persisted entry when closing the last terminal", () => {239 it("resets to default and clears persisted entry when closing the last terminal", () => {
198 const store = useTerminalStateStore.getState();240 const store = useTerminalStateStore.getState();
199 store.closeTerminal(THREAD_ID, "default");241 store.closeTerminal(THREAD_REF, "default");
200242
201 expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined();
202 expect(243 expect(
203 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)244 useTerminalStateStore.getState().terminalStateByThreadKey[scopedThreadKey(THREAD_REF)],
204 .terminalIds,245 ).toBeUndefined();
246 expect(
247 selectThreadTerminalState(
248 useTerminalStateStore.getState().terminalStateByThreadKey,
249 THREAD_REF,
250 ).terminalIds,
205 ).toEqual(["default"]);251 ).toEqual(["default"]);
206 });252 });
207253
208 it("keeps a valid active terminal after closing an active split terminal", () => {254 it("keeps a valid active terminal after closing an active split terminal", () => {
209 const store = useTerminalStateStore.getState();255 const store = useTerminalStateStore.getState();
210 store.splitTerminal(THREAD_ID, "terminal-2");256 store.splitTerminal(THREAD_REF, "terminal-2");
211 store.splitTerminal(THREAD_ID, "terminal-3");257 store.splitTerminal(THREAD_REF, "terminal-3");
212 store.closeTerminal(THREAD_ID, "terminal-3");258 store.closeTerminal(THREAD_REF, "terminal-3");
213259
214 const terminalState = selectThreadTerminalState(260 const terminalState = selectThreadTerminalState(
215 useTerminalStateStore.getState().terminalStateByThreadId,261 useTerminalStateStore.getState().terminalStateByThreadKey,
216 THREAD_ID,262 THREAD_REF,
217 );263 );
218 expect(terminalState.activeTerminalId).toBe("terminal-2");264 expect(terminalState.activeTerminalId).toBe("terminal-2");
219 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);265 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
...@@ -224,12 +270,12 @@ describe("terminalStateStore actions", () => {...@@ -224,12 +270,12 @@ describe("terminalStateStore actions", () => {
224270
225 it("buffers terminal events outside persisted terminal UI state", () => {271 it("buffers terminal events outside persisted terminal UI state", () => {
226 const store = useTerminalStateStore.getState();272 const store = useTerminalStateStore.getState();
227 store.recordTerminalEvent(makeTerminalEvent("output"));273 store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output"));
228 store.recordTerminalEvent(makeTerminalEvent("activity"));274 store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("activity"));
229275
230 const entries = selectTerminalEventEntries(276 const entries = selectTerminalEventEntries(
231 useTerminalStateStore.getState().terminalEventEntriesByKey,277 useTerminalStateStore.getState().terminalEventEntriesByKey,
232 THREAD_ID,278 THREAD_REF,
233 "default",279 "default",
234 );280 );
235281
...@@ -241,6 +287,7 @@ describe("terminalStateStore actions", () => {...@@ -241,6 +287,7 @@ describe("terminalStateStore actions", () => {
241 it("applies started terminal events to terminal state, launch context, and event buffer", () => {287 it("applies started terminal events to terminal state, launch context, and event buffer", () => {
242 const store = useTerminalStateStore.getState();288 const store = useTerminalStateStore.getState();
243 store.applyTerminalEvent(289 store.applyTerminalEvent(
290 THREAD_REF,
244 makeTerminalEvent("started", {291 makeTerminalEvent("started", {
245 terminalId: "setup-bootstrap",292 terminalId: "setup-bootstrap",
246 snapshot: {293 snapshot: {
...@@ -259,19 +306,23 @@ describe("terminalStateStore actions", () => {...@@ -259,19 +306,23 @@ describe("terminalStateStore actions", () => {
259 );306 );
260307
261 const terminalState = selectThreadTerminalState(308 const terminalState = selectThreadTerminalState(
262 useTerminalStateStore.getState().terminalStateByThreadId,309 useTerminalStateStore.getState().terminalStateByThreadKey,
263 THREAD_ID,310 THREAD_REF,
264 );311 );
265 const entries = selectTerminalEventEntries(312 const entries = selectTerminalEventEntries(
266 useTerminalStateStore.getState().terminalEventEntriesByKey,313 useTerminalStateStore.getState().terminalEventEntriesByKey,
267 THREAD_ID,314 THREAD_REF,
268 "setup-bootstrap",315 "setup-bootstrap",
269 );316 );
270317
271 expect(terminalState.terminalOpen).toBe(true);318 expect(terminalState.terminalOpen).toBe(true);
272 expect(terminalState.activeTerminalId).toBe("setup-bootstrap");319 expect(terminalState.activeTerminalId).toBe("setup-bootstrap");
273 expect(terminalState.terminalIds).toEqual(["default", "setup-bootstrap"]);320 expect(terminalState.terminalIds).toEqual(["default", "setup-bootstrap"]);
274 expect(useTerminalStateStore.getState().terminalLaunchContextByThreadId[THREAD_ID]).toEqual({321 expect(
322 useTerminalStateStore.getState().terminalLaunchContextByThreadKey[
323 scopedThreadKey(THREAD_REF)
324 ],
325 ).toEqual({
275 cwd: "/tmp/worktree",326 cwd: "/tmp/worktree",
276 worktreePath: "/tmp/worktree",327 worktreePath: "/tmp/worktree",
277 });328 });
...@@ -281,20 +332,24 @@ describe("terminalStateStore actions", () => {...@@ -281,20 +332,24 @@ describe("terminalStateStore actions", () => {
281332
282 it("applies activity and exited terminal events to subprocess state while buffering events", () => {333 it("applies activity and exited terminal events to subprocess state while buffering events", () => {
283 const store = useTerminalStateStore.getState();334 const store = useTerminalStateStore.getState();
284 store.ensureTerminal(THREAD_ID, "terminal-2", { open: true, active: true });335 store.ensureTerminal(THREAD_REF, "terminal-2", { open: true, active: true });
285336
286 store.applyTerminalEvent(337 store.applyTerminalEvent(
338 THREAD_REF,
287 makeTerminalEvent("activity", {339 makeTerminalEvent("activity", {
288 terminalId: "terminal-2",340 terminalId: "terminal-2",
289 hasRunningSubprocess: true,341 hasRunningSubprocess: true,
290 }),342 }),
291 );343 );
292 expect(344 expect(
293 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)345 selectThreadTerminalState(
294 .runningTerminalIds,346 useTerminalStateStore.getState().terminalStateByThreadKey,
347 THREAD_REF,
348 ).runningTerminalIds,
295 ).toEqual(["terminal-2"]);349 ).toEqual(["terminal-2"]);
296350
297 store.applyTerminalEvent(351 store.applyTerminalEvent(
352 THREAD_REF,
298 makeTerminalEvent("exited", {353 makeTerminalEvent("exited", {
299 terminalId: "terminal-2",354 terminalId: "terminal-2",
300 exitCode: 0,355 exitCode: 0,
...@@ -303,12 +358,12 @@ describe("terminalStateStore actions", () => {...@@ -303,12 +358,12 @@ describe("terminalStateStore actions", () => {
303 );358 );
304359
305 const terminalState = selectThreadTerminalState(360 const terminalState = selectThreadTerminalState(
306 useTerminalStateStore.getState().terminalStateByThreadId,361 useTerminalStateStore.getState().terminalStateByThreadKey,
307 THREAD_ID,362 THREAD_REF,
308 );363 );
309 const entries = selectTerminalEventEntries(364 const entries = selectTerminalEventEntries(
310 useTerminalStateStore.getState().terminalEventEntriesByKey,365 useTerminalStateStore.getState().terminalEventEntriesByKey,
311 THREAD_ID,366 THREAD_REF,
312 "terminal-2",367 "terminal-2",
313 );368 );
314369
...@@ -318,12 +373,12 @@ describe("terminalStateStore actions", () => {...@@ -318,12 +373,12 @@ describe("terminalStateStore actions", () => {
318373
319 it("clears buffered terminal events when a thread terminal state is removed", () => {374 it("clears buffered terminal events when a thread terminal state is removed", () => {
320 const store = useTerminalStateStore.getState();375 const store = useTerminalStateStore.getState();
321 store.recordTerminalEvent(makeTerminalEvent("output"));376 store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output"));
322 store.removeTerminalState(THREAD_ID);377 store.removeTerminalState(THREAD_REF);
323378
324 const entries = selectTerminalEventEntries(379 const entries = selectTerminalEventEntries(
325 useTerminalStateStore.getState().terminalEventEntriesByKey,380 useTerminalStateStore.getState().terminalEventEntriesByKey,
326 THREAD_ID,381 THREAD_REF,
327 "default",382 "default",
328 );383 );
329384
...@@ -334,7 +389,7 @@ describe("terminalStateStore actions", () => {...@@ -334,7 +389,7 @@ describe("terminalStateStore actions", () => {
334 const store = useTerminalStateStore.getState();389 const store = useTerminalStateStore.getState();
335 const before = useTerminalStateStore.getState();390 const before = useTerminalStateStore.getState();
336391
337 store.clearTerminalState(THREAD_ID);392 store.clearTerminalState(THREAD_REF);
338393
339 expect(useTerminalStateStore.getState()).toBe(before);394 expect(useTerminalStateStore.getState()).toBe(before);
340 });395 });
apps/web/src/terminalStateStore.ts+165-127
...@@ -1,11 +1,12 @@...@@ -1,11 +1,12 @@
1/**1/**
2 * Single Zustand store for terminal UI state keyed by threadId.2 * Single Zustand store for terminal UI state keyed by scoped thread identity.
3 *3 *
4 * Terminal transition helpers are intentionally private to keep the public4 * Terminal transition helpers are intentionally private to keep the public
5 * API constrained to store actions/selectors.5 * API constrained to store actions/selectors.
6 */6 */
77
8import { ThreadId, type TerminalEvent } from "@t3tools/contracts";8import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime";
9import { type ScopedThreadRef, type TerminalEvent } from "@t3tools/contracts";
9import { create } from "zustand";10import { create } from "zustand";
10import { createJSONStorage, persist } from "zustand/middleware";11import { createJSONStorage, persist } from "zustand/middleware";
11import { resolveStorage } from "./lib/storage";12import { resolveStorage } from "./lib/storage";
...@@ -41,6 +42,26 @@ const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";...@@ -41,6 +42,26 @@ const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
41const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = [];42const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = [];
42const MAX_TERMINAL_EVENT_BUFFER = 200;43const MAX_TERMINAL_EVENT_BUFFER = 200;
4344
45interface PersistedTerminalStateStoreState {
46 terminalStateByThreadKey?: Record<string, ThreadTerminalState>;
47}
48
49export function migratePersistedTerminalStateStoreState(
50 persistedState: unknown,
51 version: number,
52): PersistedTerminalStateStoreState {
53 if (version === 1 && persistedState && typeof persistedState === "object") {
54 const candidate = persistedState as PersistedTerminalStateStoreState;
55 const nextTerminalStateByThreadKey = Object.fromEntries(
56 Object.entries(candidate.terminalStateByThreadKey ?? {}).filter(([threadKey]) =>
57 parseScopedThreadKey(threadKey),
58 ),
59 );
60 return { terminalStateByThreadKey: nextTerminalStateByThreadKey };
61 }
62 return { terminalStateByThreadKey: {} };
63}
64
44function createTerminalStateStorage() {65function createTerminalStateStorage() {
45 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);66 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
46}67}
...@@ -240,8 +261,12 @@ function isValidTerminalId(terminalId: string): boolean {...@@ -240,8 +261,12 @@ function isValidTerminalId(terminalId: string): boolean {
240 return terminalId.trim().length > 0;261 return terminalId.trim().length > 0;
241}262}
242263
243function terminalEventBufferKey(threadId: ThreadId, terminalId: string): string {264function terminalThreadKey(threadRef: ScopedThreadRef): string {
244 return `${threadId}\u0000${terminalId}`;265 return scopedThreadKey(threadRef);
266}
267
268function terminalEventBufferKey(threadRef: ScopedThreadRef, terminalId: string): string {
269 return `${terminalThreadKey(threadRef)}\u0000${terminalId}`;
245}270}
246271
247function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {272function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
...@@ -254,9 +279,10 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[...@@ -254,9 +279,10 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[
254function appendTerminalEventEntry(279function appendTerminalEventEntry(
255 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>,280 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>,
256 nextTerminalEventId: number,281 nextTerminalEventId: number,
282 threadRef: ScopedThreadRef,
257 event: TerminalEvent,283 event: TerminalEvent,
258) {284) {
259 const key = terminalEventBufferKey(ThreadId.makeUnsafe(event.threadId), event.terminalId);285 const key = terminalEventBufferKey(threadRef, event.terminalId);
260 const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES;286 const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES;
261 const nextEntry: TerminalEventEntry = {287 const nextEntry: TerminalEventEntry = {
262 id: nextTerminalEventId,288 id: nextTerminalEventId,
...@@ -484,125 +510,129 @@ function setThreadTerminalActivity(...@@ -484,125 +510,129 @@ function setThreadTerminalActivity(
484}510}
485511
486export function selectThreadTerminalState(512export function selectThreadTerminalState(
487 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,513 terminalStateByThreadKey: Record<string, ThreadTerminalState>,
488 threadId: ThreadId,514 threadRef: ScopedThreadRef | null | undefined,
489): ThreadTerminalState {515): ThreadTerminalState {
490 if (threadId.length === 0) {516 if (!threadRef || threadRef.threadId.length === 0) {
491 return getDefaultThreadTerminalState();517 return getDefaultThreadTerminalState();
492 }518 }
493 return terminalStateByThreadId[threadId] ?? getDefaultThreadTerminalState();519 return terminalStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalState();
494}520}
495521
496function updateTerminalStateByThreadId(522function updateTerminalStateByThreadKey(
497 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,523 terminalStateByThreadKey: Record<string, ThreadTerminalState>,
498 threadId: ThreadId,524 threadRef: ScopedThreadRef,
499 updater: (state: ThreadTerminalState) => ThreadTerminalState,525 updater: (state: ThreadTerminalState) => ThreadTerminalState,
500): Record<ThreadId, ThreadTerminalState> {526): Record<string, ThreadTerminalState> {
501 if (threadId.length === 0) {527 if (threadRef.threadId.length === 0) {
502 return terminalStateByThreadId;528 return terminalStateByThreadKey;
503 }529 }
504530
505 const current = selectThreadTerminalState(terminalStateByThreadId, threadId);531 const threadKey = terminalThreadKey(threadRef);
532 const current = selectThreadTerminalState(terminalStateByThreadKey, threadRef);
506 const next = updater(current);533 const next = updater(current);
507 if (next === current) {534 if (next === current) {
508 return terminalStateByThreadId;535 return terminalStateByThreadKey;
509 }536 }
510537
511 if (isDefaultThreadTerminalState(next)) {538 if (isDefaultThreadTerminalState(next)) {
512 if (terminalStateByThreadId[threadId] === undefined) {539 if (terminalStateByThreadKey[threadKey] === undefined) {
513 return terminalStateByThreadId;540 return terminalStateByThreadKey;
514 }541 }
515 const { [threadId]: _removed, ...rest } = terminalStateByThreadId;542 const { [threadKey]: _removed, ...rest } = terminalStateByThreadKey;
516 return rest as Record<ThreadId, ThreadTerminalState>;543 return rest;
517 }544 }
518545
519 return {546 return {
520 ...terminalStateByThreadId,547 ...terminalStateByThreadKey,
521 [threadId]: next,548 [threadKey]: next,
522 };549 };
523}550}
524551
525export function selectTerminalEventEntries(552export function selectTerminalEventEntries(
526 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>,553 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>,
527 threadId: ThreadId,554 threadRef: ScopedThreadRef | null | undefined,
528 terminalId: string,555 terminalId: string,
529): ReadonlyArray<TerminalEventEntry> {556): ReadonlyArray<TerminalEventEntry> {
530 if (threadId.length === 0 || terminalId.trim().length === 0) {557 if (!threadRef || threadRef.threadId.length === 0 || terminalId.trim().length === 0) {
531 return EMPTY_TERMINAL_EVENT_ENTRIES;558 return EMPTY_TERMINAL_EVENT_ENTRIES;
532 }559 }
533 return (560 return (
534 terminalEventEntriesByKey[terminalEventBufferKey(threadId, terminalId)] ??561 terminalEventEntriesByKey[terminalEventBufferKey(threadRef, terminalId)] ??
535 EMPTY_TERMINAL_EVENT_ENTRIES562 EMPTY_TERMINAL_EVENT_ENTRIES
536 );563 );
537}564}
538565
539interface TerminalStateStoreState {566interface TerminalStateStoreState {
540 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;567 terminalStateByThreadKey: Record<string, ThreadTerminalState>;
541 terminalLaunchContextByThreadId: Record<ThreadId, ThreadTerminalLaunchContext>;568 terminalLaunchContextByThreadKey: Record<string, ThreadTerminalLaunchContext>;
542 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>;569 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>;
543 nextTerminalEventId: number;570 nextTerminalEventId: number;
544 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;571 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
545 setTerminalHeight: (threadId: ThreadId, height: number) => void;572 setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void;
546 splitTerminal: (threadId: ThreadId, terminalId: string) => void;573 splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
547 newTerminal: (threadId: ThreadId, terminalId: string) => void;574 newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
548 ensureTerminal: (575 ensureTerminal: (
549 threadId: ThreadId,576 threadRef: ScopedThreadRef,
550 terminalId: string,577 terminalId: string,
551 options?: { open?: boolean; active?: boolean },578 options?: { open?: boolean; active?: boolean },
552 ) => void;579 ) => void;
553 setActiveTerminal: (threadId: ThreadId, terminalId: string) => void;580 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
554 closeTerminal: (threadId: ThreadId, terminalId: string) => void;581 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
555 setTerminalLaunchContext: (threadId: ThreadId, context: ThreadTerminalLaunchContext) => void;582 setTerminalLaunchContext: (
556 clearTerminalLaunchContext: (threadId: ThreadId) => void;583 threadRef: ScopedThreadRef,
584 context: ThreadTerminalLaunchContext,
585 ) => void;
586 clearTerminalLaunchContext: (threadRef: ScopedThreadRef) => void;
557 setTerminalActivity: (587 setTerminalActivity: (
558 threadId: ThreadId,588 threadRef: ScopedThreadRef,
559 terminalId: string,589 terminalId: string,
560 hasRunningSubprocess: boolean,590 hasRunningSubprocess: boolean,
561 ) => void;591 ) => void;
562 recordTerminalEvent: (event: TerminalEvent) => void;592 recordTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void;
563 applyTerminalEvent: (event: TerminalEvent) => void;593 applyTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void;
564 clearTerminalState: (threadId: ThreadId) => void;594 clearTerminalState: (threadRef: ScopedThreadRef) => void;
565 removeTerminalState: (threadId: ThreadId) => void;595 removeTerminalState: (threadRef: ScopedThreadRef) => void;
566 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;596 removeOrphanedTerminalStates: (activeThreadKeys: Set<string>) => void;
567}597}
568598
569export const useTerminalStateStore = create<TerminalStateStoreState>()(599export const useTerminalStateStore = create<TerminalStateStoreState>()(
570 persist(600 persist(
571 (set) => {601 (set) => {
572 const updateTerminal = (602 const updateTerminal = (
573 threadId: ThreadId,603 threadRef: ScopedThreadRef,
574 updater: (state: ThreadTerminalState) => ThreadTerminalState,604 updater: (state: ThreadTerminalState) => ThreadTerminalState,
575 ) => {605 ) => {
576 set((state) => {606 set((state) => {
577 const nextTerminalStateByThreadId = updateTerminalStateByThreadId(607 const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
578 state.terminalStateByThreadId,608 state.terminalStateByThreadKey,
579 threadId,609 threadRef,
580 updater,610 updater,
581 );611 );
582 if (nextTerminalStateByThreadId === state.terminalStateByThreadId) {612 if (nextTerminalStateByThreadKey === state.terminalStateByThreadKey) {
583 return state;613 return state;
584 }614 }
585 return {615 return {
586 terminalStateByThreadId: nextTerminalStateByThreadId,616 terminalStateByThreadKey: nextTerminalStateByThreadKey,
587 };617 };
588 });618 });
589 };619 };
590620
591 return {621 return {
592 terminalStateByThreadId: {},622 terminalStateByThreadKey: {},
593 terminalLaunchContextByThreadId: {},623 terminalLaunchContextByThreadKey: {},
594 terminalEventEntriesByKey: {},624 terminalEventEntriesByKey: {},
595 nextTerminalEventId: 1,625 nextTerminalEventId: 1,
596 setTerminalOpen: (threadId, open) =>626 setTerminalOpen: (threadRef, open) =>
597 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),627 updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)),
598 setTerminalHeight: (threadId, height) =>628 setTerminalHeight: (threadRef, height) =>
599 updateTerminal(threadId, (state) => setThreadTerminalHeight(state, height)),629 updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)),
600 splitTerminal: (threadId, terminalId) =>630 splitTerminal: (threadRef, terminalId) =>
601 updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)),631 updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)),
602 newTerminal: (threadId, terminalId) =>632 newTerminal: (threadRef, terminalId) =>
603 updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)),633 updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)),
604 ensureTerminal: (threadId, terminalId, options) =>634 ensureTerminal: (threadRef, terminalId, options) =>
605 updateTerminal(threadId, (state) => {635 updateTerminal(threadRef, (state) => {
606 let nextState = state;636 let nextState = state;
607 if (!state.terminalIds.includes(terminalId)) {637 if (!state.terminalIds.includes(terminalId)) {
608 nextState = newThreadTerminal(nextState, terminalId);638 nextState = newThreadTerminal(nextState, terminalId);
...@@ -622,47 +652,49 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -622,47 +652,49 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
622 }652 }
623 return normalizeThreadTerminalState(nextState);653 return normalizeThreadTerminalState(nextState);
624 }),654 }),
625 setActiveTerminal: (threadId, terminalId) =>655 setActiveTerminal: (threadRef, terminalId) =>
626 updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)),656 updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)),
627 closeTerminal: (threadId, terminalId) =>657 closeTerminal: (threadRef, terminalId) =>
628 updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)),658 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)),
629 setTerminalLaunchContext: (threadId, context) =>659 setTerminalLaunchContext: (threadRef, context) =>
630 set((state) => ({660 set((state) => ({
631 terminalLaunchContextByThreadId: {661 terminalLaunchContextByThreadKey: {
632 ...state.terminalLaunchContextByThreadId,662 ...state.terminalLaunchContextByThreadKey,
633 [threadId]: context,663 [terminalThreadKey(threadRef)]: context,
634 },664 },
635 })),665 })),
636 clearTerminalLaunchContext: (threadId) =>666 clearTerminalLaunchContext: (threadRef) =>
637 set((state) => {667 set((state) => {
638 if (!state.terminalLaunchContextByThreadId[threadId]) {668 const threadKey = terminalThreadKey(threadRef);
669 if (!state.terminalLaunchContextByThreadKey[threadKey]) {
639 return state;670 return state;
640 }671 }
641 const { [threadId]: _removed, ...rest } = state.terminalLaunchContextByThreadId;672 const { [threadKey]: _removed, ...rest } = state.terminalLaunchContextByThreadKey;
642 return { terminalLaunchContextByThreadId: rest };673 return { terminalLaunchContextByThreadKey: rest };
643 }),674 }),
644 setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>675 setTerminalActivity: (threadRef, terminalId, hasRunningSubprocess) =>
645 updateTerminal(threadId, (state) =>676 updateTerminal(threadRef, (state) =>
646 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),677 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),
647 ),678 ),
648 recordTerminalEvent: (event) =>679 recordTerminalEvent: (threadRef, event) =>
649 set((state) =>680 set((state) =>
650 appendTerminalEventEntry(681 appendTerminalEventEntry(
651 state.terminalEventEntriesByKey,682 state.terminalEventEntriesByKey,
652 state.nextTerminalEventId,683 state.nextTerminalEventId,
684 threadRef,
653 event,685 event,
654 ),686 ),
655 ),687 ),
656 applyTerminalEvent: (event) =>688 applyTerminalEvent: (threadRef, event) =>
657 set((state) => {689 set((state) => {
658 const threadId = ThreadId.makeUnsafe(event.threadId);690 const threadKey = terminalThreadKey(threadRef);
659 let nextTerminalStateByThreadId = state.terminalStateByThreadId;691 let nextTerminalStateByThreadKey = state.terminalStateByThreadKey;
660 let nextTerminalLaunchContextByThreadId = state.terminalLaunchContextByThreadId;692 let nextTerminalLaunchContextByThreadKey = state.terminalLaunchContextByThreadKey;
661693
662 if (event.type === "started" || event.type === "restarted") {694 if (event.type === "started" || event.type === "restarted") {
663 nextTerminalStateByThreadId = updateTerminalStateByThreadId(695 nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
664 nextTerminalStateByThreadId,696 nextTerminalStateByThreadKey,
665 threadId,697 threadRef,
666 (current) => {698 (current) => {
667 let nextState = current;699 let nextState = current;
668 if (!current.terminalIds.includes(event.terminalId)) {700 if (!current.terminalIds.includes(event.terminalId)) {
...@@ -673,17 +705,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -673,17 +705,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
673 return normalizeThreadTerminalState(nextState);705 return normalizeThreadTerminalState(nextState);
674 },706 },
675 );707 );
676 nextTerminalLaunchContextByThreadId = {708 nextTerminalLaunchContextByThreadKey = {
677 ...nextTerminalLaunchContextByThreadId,709 ...nextTerminalLaunchContextByThreadKey,
678 [threadId]: launchContextFromStartEvent(event),710 [threadKey]: launchContextFromStartEvent(event),
679 };711 };
680 }712 }
681713
682 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);714 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);
683 if (hasRunningSubprocess !== null) {715 if (hasRunningSubprocess !== null) {
684 nextTerminalStateByThreadId = updateTerminalStateByThreadId(716 nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
685 nextTerminalStateByThreadId,717 nextTerminalStateByThreadKey,
686 threadId,718 threadRef,
687 (current) =>719 (current) =>
688 setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess),720 setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess),
689 );721 );
...@@ -692,54 +724,59 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -692,54 +724,59 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
692 const nextEventState = appendTerminalEventEntry(724 const nextEventState = appendTerminalEventEntry(
693 state.terminalEventEntriesByKey,725 state.terminalEventEntriesByKey,
694 state.nextTerminalEventId,726 state.nextTerminalEventId,
727 threadRef,
695 event,728 event,
696 );729 );
697730
698 return {731 return {
699 terminalStateByThreadId: nextTerminalStateByThreadId,732 terminalStateByThreadKey: nextTerminalStateByThreadKey,
700 terminalLaunchContextByThreadId: nextTerminalLaunchContextByThreadId,733 terminalLaunchContextByThreadKey: nextTerminalLaunchContextByThreadKey,
701 ...nextEventState,734 ...nextEventState,
702 };735 };
703 }),736 }),
704 clearTerminalState: (threadId) =>737 clearTerminalState: (threadRef) =>
705 set((state) => {738 set((state) => {
706 const nextTerminalStateByThreadId = updateTerminalStateByThreadId(739 const threadKey = terminalThreadKey(threadRef);
707 state.terminalStateByThreadId,740 const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
708 threadId,741 state.terminalStateByThreadKey,
742 threadRef,
709 () => createDefaultThreadTerminalState(),743 () => createDefaultThreadTerminalState(),
710 );744 );
711 const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined;745 const hadLaunchContext =
712 const { [threadId]: _removed, ...remainingLaunchContexts } =746 state.terminalLaunchContextByThreadKey[threadKey] !== undefined;
713 state.terminalLaunchContextByThreadId;747 const { [threadKey]: _removed, ...remainingLaunchContexts } =
748 state.terminalLaunchContextByThreadKey;
714 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };749 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
715 let removedEventEntries = false;750 let removedEventEntries = false;
716 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {751 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
717 if (key.startsWith(`${threadId}\u0000`)) {752 if (key.startsWith(`${threadKey}\u0000`)) {
718 delete nextTerminalEventEntriesByKey[key];753 delete nextTerminalEventEntriesByKey[key];
719 removedEventEntries = true;754 removedEventEntries = true;
720 }755 }
721 }756 }
722 if (757 if (
723 nextTerminalStateByThreadId === state.terminalStateByThreadId &&758 nextTerminalStateByThreadKey === state.terminalStateByThreadKey &&
724 !hadLaunchContext &&759 !hadLaunchContext &&
725 !removedEventEntries760 !removedEventEntries
726 ) {761 ) {
727 return state;762 return state;
728 }763 }
729 return {764 return {
730 terminalStateByThreadId: nextTerminalStateByThreadId,765 terminalStateByThreadKey: nextTerminalStateByThreadKey,
731 terminalLaunchContextByThreadId: remainingLaunchContexts,766 terminalLaunchContextByThreadKey: remainingLaunchContexts,
732 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,767 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
733 };768 };
734 }),769 }),
735 removeTerminalState: (threadId) =>770 removeTerminalState: (threadRef) =>
736 set((state) => {771 set((state) => {
737 const hadTerminalState = state.terminalStateByThreadId[threadId] !== undefined;772 const threadKey = terminalThreadKey(threadRef);
738 const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined;773 const hadTerminalState = state.terminalStateByThreadKey[threadKey] !== undefined;
774 const hadLaunchContext =
775 state.terminalLaunchContextByThreadKey[threadKey] !== undefined;
739 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };776 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
740 let removedEventEntries = false;777 let removedEventEntries = false;
741 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {778 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
742 if (key.startsWith(`${threadId}\u0000`)) {779 if (key.startsWith(`${threadKey}\u0000`)) {
743 delete nextTerminalEventEntriesByKey[key];780 delete nextTerminalEventEntriesByKey[key];
744 removedEventEntries = true;781 removedEventEntries = true;
745 }782 }
...@@ -747,29 +784,29 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -747,29 +784,29 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
747 if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) {784 if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) {
748 return state;785 return state;
749 }786 }
750 const nextTerminalStateByThreadId = { ...state.terminalStateByThreadId };787 const nextTerminalStateByThreadKey = { ...state.terminalStateByThreadKey };
751 delete nextTerminalStateByThreadId[threadId];788 delete nextTerminalStateByThreadKey[threadKey];
752 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId };789 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey };
753 delete nextLaunchContexts[threadId];790 delete nextLaunchContexts[threadKey];
754 return {791 return {
755 terminalStateByThreadId: nextTerminalStateByThreadId,792 terminalStateByThreadKey: nextTerminalStateByThreadKey,
756 terminalLaunchContextByThreadId: nextLaunchContexts,793 terminalLaunchContextByThreadKey: nextLaunchContexts,
757 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,794 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
758 };795 };
759 }),796 }),
760 removeOrphanedTerminalStates: (activeThreadIds) =>797 removeOrphanedTerminalStates: (activeThreadKeys) =>
761 set((state) => {798 set((state) => {
762 const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(799 const orphanedIds = Object.keys(state.terminalStateByThreadKey).filter(
763 (id) => !activeThreadIds.has(id as ThreadId),800 (key) => !activeThreadKeys.has(key),
764 );801 );
765 const orphanedLaunchContextIds = Object.keys(802 const orphanedLaunchContextIds = Object.keys(
766 state.terminalLaunchContextByThreadId,803 state.terminalLaunchContextByThreadKey,
767 ).filter((id) => !activeThreadIds.has(id as ThreadId));804 ).filter((key) => !activeThreadKeys.has(key));
768 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };805 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
769 let removedEventEntries = false;806 let removedEventEntries = false;
770 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {807 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
771 const [threadId] = key.split("\u0000");808 const [threadKey] = key.split("\u0000");
772 if (threadId && !activeThreadIds.has(threadId as ThreadId)) {809 if (threadKey && !activeThreadKeys.has(threadKey)) {
773 delete nextTerminalEventEntriesByKey[key];810 delete nextTerminalEventEntriesByKey[key];
774 removedEventEntries = true;811 removedEventEntries = true;
775 }812 }
...@@ -781,17 +818,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -781,17 +818,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
781 ) {818 ) {
782 return state;819 return state;
783 }820 }
784 const next = { ...state.terminalStateByThreadId };821 const next = { ...state.terminalStateByThreadKey };
785 for (const id of orphanedIds) {822 for (const id of orphanedIds) {
786 delete next[id as ThreadId];823 delete next[id];
787 }824 }
788 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId };825 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey };
789 for (const id of orphanedLaunchContextIds) {826 for (const id of orphanedLaunchContextIds) {
790 delete nextLaunchContexts[id as ThreadId];827 delete nextLaunchContexts[id];
791 }828 }
792 return {829 return {
793 terminalStateByThreadId: next,830 terminalStateByThreadKey: next,
794 terminalLaunchContextByThreadId: nextLaunchContexts,831 terminalLaunchContextByThreadKey: nextLaunchContexts,
795 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,832 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
796 };833 };
797 }),834 }),
...@@ -799,10 +836,11 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -799,10 +836,11 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
799 },836 },
800 {837 {
801 name: TERMINAL_STATE_STORAGE_KEY,838 name: TERMINAL_STATE_STORAGE_KEY,
802 version: 1,839 version: 2,
803 storage: createJSONStorage(createTerminalStateStorage),840 storage: createJSONStorage(createTerminalStateStorage),
841 migrate: migratePersistedTerminalStateStoreState,
804 partialize: (state) => ({842 partialize: (state) => ({
805 terminalStateByThreadId: state.terminalStateByThreadId,843 terminalStateByThreadKey: state.terminalStateByThreadKey,
806 }),844 }),
807 },845 },
808 ),846 ),
apps/web/src/threadRoutes.test.ts created+67
...@@ -0,0 +1,67 @@
1import { describe, expect, it } from "vitest";
2import { scopeThreadRef } from "@t3tools/client-runtime";
3import { ThreadId } from "@t3tools/contracts";
4import { DraftId } from "./composerDraftStore";
5
6import {
7 buildDraftThreadRouteParams,
8 buildThreadRouteParams,
9 resolveThreadRouteRef,
10 resolveThreadRouteTarget,
11} from "./threadRoutes";
12
13describe("threadRoutes", () => {
14 it("builds canonical thread route params from a scoped ref", () => {
15 const ref = scopeThreadRef("env-1" as never, ThreadId.makeUnsafe("thread-1"));
16
17 expect(buildThreadRouteParams(ref)).toEqual({
18 environmentId: "env-1",
19 threadId: "thread-1",
20 });
21 });
22
23 it("resolves a scoped ref only when both params are present", () => {
24 expect(
25 resolveThreadRouteRef({
26 environmentId: "env-1",
27 threadId: "thread-1",
28 }),
29 ).toEqual({
30 environmentId: "env-1",
31 threadId: "thread-1",
32 });
33
34 expect(resolveThreadRouteRef({ environmentId: "env-1" })).toBeNull();
35 expect(resolveThreadRouteRef({ threadId: "thread-1" })).toBeNull();
36 });
37
38 it("builds canonical draft route params from a draft id", () => {
39 expect(buildDraftThreadRouteParams(DraftId.makeUnsafe("draft-1"))).toEqual({
40 draftId: "draft-1",
41 });
42 });
43
44 it("resolves draft and server route targets", () => {
45 expect(
46 resolveThreadRouteTarget({
47 environmentId: "env-1",
48 threadId: "thread-1",
49 }),
50 ).toEqual({
51 kind: "server",
52 threadRef: {
53 environmentId: "env-1",
54 threadId: "thread-1",
55 },
56 });
57
58 expect(
59 resolveThreadRouteTarget({
60 draftId: "draft-1",
61 }),
62 ).toEqual({
63 kind: "draft",
64 draftId: "draft-1",
65 });
66 });
67});
apps/web/src/threadRoutes.ts created+59
...@@ -0,0 +1,59 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";
2import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
3import type { DraftId } from "./composerDraftStore";
4
5export type ThreadRouteTarget =
6 | {
7 kind: "server";
8 threadRef: ScopedThreadRef;
9 }
10 | {
11 kind: "draft";
12 draftId: DraftId;
13 };
14
15export function buildThreadRouteParams(ref: ScopedThreadRef): {
16 environmentId: EnvironmentId;
17 threadId: ThreadId;
18} {
19 return {
20 environmentId: ref.environmentId,
21 threadId: ref.threadId,
22 };
23}
24
25export function buildDraftThreadRouteParams(draftId: DraftId): {
26 draftId: DraftId;
27} {
28 return { draftId };
29}
30
31export function resolveThreadRouteRef(
32 params: Partial<Record<"environmentId" | "threadId", string | undefined>>,
33): ScopedThreadRef | null {
34 if (!params.environmentId || !params.threadId) {
35 return null;
36 }
37
38 return scopeThreadRef(params.environmentId as EnvironmentId, params.threadId as ThreadId);
39}
40
41export function resolveThreadRouteTarget(
42 params: Partial<Record<"environmentId" | "threadId" | "draftId", string | undefined>>,
43): ThreadRouteTarget | null {
44 if (params.environmentId && params.threadId) {
45 return {
46 kind: "server",
47 threadRef: scopeThreadRef(params.environmentId as EnvironmentId, params.threadId as ThreadId),
48 };
49 }
50
51 if (!params.draftId) {
52 return null;
53 }
54
55 return {
56 kind: "draft",
57 draftId: params.draftId as DraftId,
58 };
59}
apps/web/src/threadSelectionStore.test.ts+53-53
...@@ -21,9 +21,9 @@ describe("threadSelectionStore", () => {...@@ -21,9 +21,9 @@ describe("threadSelectionStore", () => {
21 useThreadSelectionStore.getState().toggleThread(THREAD_A);21 useThreadSelectionStore.getState().toggleThread(THREAD_A);
2222
23 const state = useThreadSelectionStore.getState();23 const state = useThreadSelectionStore.getState();
24 expect(state.selectedThreadIds.has(THREAD_A)).toBe(true);24 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true);
25 expect(state.selectedThreadIds.size).toBe(1);25 expect(state.selectedThreadKeys.size).toBe(1);
26 expect(state.anchorThreadId).toBe(THREAD_A);26 expect(state.anchorThreadKey).toBe(THREAD_A);
27 });27 });
2828
29 it("removes a thread that is already selected", () => {29 it("removes a thread that is already selected", () => {
...@@ -32,8 +32,8 @@ describe("threadSelectionStore", () => {...@@ -32,8 +32,8 @@ describe("threadSelectionStore", () => {
32 store.toggleThread(THREAD_A);32 store.toggleThread(THREAD_A);
3333
34 const state = useThreadSelectionStore.getState();34 const state = useThreadSelectionStore.getState();
35 expect(state.selectedThreadIds.has(THREAD_A)).toBe(false);35 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(false);
36 expect(state.selectedThreadIds.size).toBe(0);36 expect(state.selectedThreadKeys.size).toBe(0);
37 });37 });
3838
39 it("preserves existing selections when toggling a new thread", () => {39 it("preserves existing selections when toggling a new thread", () => {
...@@ -42,9 +42,9 @@ describe("threadSelectionStore", () => {...@@ -42,9 +42,9 @@ describe("threadSelectionStore", () => {
42 store.toggleThread(THREAD_B);42 store.toggleThread(THREAD_B);
4343
44 const state = useThreadSelectionStore.getState();44 const state = useThreadSelectionStore.getState();
45 expect(state.selectedThreadIds.has(THREAD_A)).toBe(true);45 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true);
46 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);46 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
47 expect(state.selectedThreadIds.size).toBe(2);47 expect(state.selectedThreadKeys.size).toBe(2);
48 });48 });
4949
50 it("sets anchor to the newly added thread", () => {50 it("sets anchor to the newly added thread", () => {
...@@ -52,7 +52,7 @@ describe("threadSelectionStore", () => {...@@ -52,7 +52,7 @@ describe("threadSelectionStore", () => {
52 store.toggleThread(THREAD_A);52 store.toggleThread(THREAD_A);
53 store.toggleThread(THREAD_B);53 store.toggleThread(THREAD_B);
5454
55 expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B);55 expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B);
56 });56 });
5757
58 it("preserves anchor when deselecting a non-anchor thread", () => {58 it("preserves anchor when deselecting a non-anchor thread", () => {
...@@ -61,7 +61,7 @@ describe("threadSelectionStore", () => {...@@ -61,7 +61,7 @@ describe("threadSelectionStore", () => {
61 store.toggleThread(THREAD_B);61 store.toggleThread(THREAD_B);
62 store.toggleThread(THREAD_A); // deselect A, anchor should stay B62 store.toggleThread(THREAD_A); // deselect A, anchor should stay B
6363
64 expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B);64 expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B);
65 });65 });
66 });66 });
6767
...@@ -70,8 +70,8 @@ describe("threadSelectionStore", () => {...@@ -70,8 +70,8 @@ describe("threadSelectionStore", () => {
70 useThreadSelectionStore.getState().setAnchor(THREAD_B);70 useThreadSelectionStore.getState().setAnchor(THREAD_B);
7171
72 const state = useThreadSelectionStore.getState();72 const state = useThreadSelectionStore.getState();
73 expect(state.anchorThreadId).toBe(THREAD_B);73 expect(state.anchorThreadKey).toBe(THREAD_B);
74 expect(state.selectedThreadIds.size).toBe(0);74 expect(state.selectedThreadKeys.size).toBe(0);
75 });75 });
7676
77 it("enables range select from a plain-click anchor", () => {77 it("enables range select from a plain-click anchor", () => {
...@@ -80,10 +80,10 @@ describe("threadSelectionStore", () => {...@@ -80,10 +80,10 @@ describe("threadSelectionStore", () => {
80 store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D80 store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D
8181
82 const state = useThreadSelectionStore.getState();82 const state = useThreadSelectionStore.getState();
83 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);83 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
84 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);84 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
85 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);85 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
86 expect(state.selectedThreadIds.size).toBe(3);86 expect(state.selectedThreadKeys.size).toBe(3);
87 });87 });
8888
89 it("is a no-op when anchor is already set to the same thread", () => {89 it("is a no-op when anchor is already set to the same thread", () => {
...@@ -105,8 +105,8 @@ describe("threadSelectionStore", () => {...@@ -105,8 +105,8 @@ describe("threadSelectionStore", () => {
105 store.setAnchor(THREAD_C);105 store.setAnchor(THREAD_C);
106106
107 const state = useThreadSelectionStore.getState();107 const state = useThreadSelectionStore.getState();
108 expect(state.anchorThreadId).toBe(THREAD_C);108 expect(state.anchorThreadKey).toBe(THREAD_C);
109 expect(state.selectedThreadIds.size).toBe(0);109 expect(state.selectedThreadKeys.size).toBe(0);
110 });110 });
111 });111 });
112112
...@@ -115,9 +115,9 @@ describe("threadSelectionStore", () => {...@@ -115,9 +115,9 @@ describe("threadSelectionStore", () => {
115 useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED);115 useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED);
116116
117 const state = useThreadSelectionStore.getState();117 const state = useThreadSelectionStore.getState();
118 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);118 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
119 expect(state.selectedThreadIds.size).toBe(1);119 expect(state.selectedThreadKeys.size).toBe(1);
120 expect(state.anchorThreadId).toBe(THREAD_C);120 expect(state.anchorThreadKey).toBe(THREAD_C);
121 });121 });
122122
123 it("selects range from anchor to target (forward)", () => {123 it("selects range from anchor to target (forward)", () => {
...@@ -126,10 +126,10 @@ describe("threadSelectionStore", () => {...@@ -126,10 +126,10 @@ describe("threadSelectionStore", () => {
126 store.rangeSelectTo(THREAD_D, ORDERED);126 store.rangeSelectTo(THREAD_D, ORDERED);
127127
128 const state = useThreadSelectionStore.getState();128 const state = useThreadSelectionStore.getState();
129 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);129 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
130 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);130 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
131 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);131 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
132 expect(state.selectedThreadIds.size).toBe(3);132 expect(state.selectedThreadKeys.size).toBe(3);
133 });133 });
134134
135 it("selects range from anchor to target (backward)", () => {135 it("selects range from anchor to target (backward)", () => {
...@@ -138,10 +138,10 @@ describe("threadSelectionStore", () => {...@@ -138,10 +138,10 @@ describe("threadSelectionStore", () => {
138 store.rangeSelectTo(THREAD_B, ORDERED);138 store.rangeSelectTo(THREAD_B, ORDERED);
139139
140 const state = useThreadSelectionStore.getState();140 const state = useThreadSelectionStore.getState();
141 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);141 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
142 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);142 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
143 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);143 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
144 expect(state.selectedThreadIds.size).toBe(3);144 expect(state.selectedThreadKeys.size).toBe(3);
145 });145 });
146146
147 it("keeps anchor stable across multiple range selects", () => {147 it("keeps anchor stable across multiple range selects", () => {
...@@ -151,11 +151,11 @@ describe("threadSelectionStore", () => {...@@ -151,11 +151,11 @@ describe("threadSelectionStore", () => {
151 store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B)151 store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B)
152152
153 const state = useThreadSelectionStore.getState();153 const state = useThreadSelectionStore.getState();
154 expect(state.anchorThreadId).toBe(THREAD_B);154 expect(state.anchorThreadKey).toBe(THREAD_B);
155 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);155 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
156 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);156 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
157 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);157 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
158 expect(state.selectedThreadIds.has(THREAD_E)).toBe(true);158 expect(state.selectedThreadKeys.has(THREAD_E)).toBe(true);
159 });159 });
160160
161 it("falls back to toggle when anchor is not in the ordered list", () => {161 it("falls back to toggle when anchor is not in the ordered list", () => {
...@@ -166,8 +166,8 @@ describe("threadSelectionStore", () => {...@@ -166,8 +166,8 @@ describe("threadSelectionStore", () => {
166166
167 const state = useThreadSelectionStore.getState();167 const state = useThreadSelectionStore.getState();
168 // Should have added C and reset anchor to C168 // Should have added C and reset anchor to C
169 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);169 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
170 expect(state.anchorThreadId).toBe(THREAD_C);170 expect(state.anchorThreadKey).toBe(THREAD_C);
171 });171 });
172172
173 it("falls back to toggle when target is not in the ordered list", () => {173 it("falls back to toggle when target is not in the ordered list", () => {
...@@ -177,8 +177,8 @@ describe("threadSelectionStore", () => {...@@ -177,8 +177,8 @@ describe("threadSelectionStore", () => {
177 store.rangeSelectTo(unknownThread, ORDERED);177 store.rangeSelectTo(unknownThread, ORDERED);
178178
179 const state = useThreadSelectionStore.getState();179 const state = useThreadSelectionStore.getState();
180 expect(state.selectedThreadIds.has(unknownThread)).toBe(true);180 expect(state.selectedThreadKeys.has(unknownThread)).toBe(true);
181 expect(state.anchorThreadId).toBe(unknownThread);181 expect(state.anchorThreadKey).toBe(unknownThread);
182 });182 });
183183
184 it("selects the single thread when anchor equals target", () => {184 it("selects the single thread when anchor equals target", () => {
...@@ -187,8 +187,8 @@ describe("threadSelectionStore", () => {...@@ -187,8 +187,8 @@ describe("threadSelectionStore", () => {
187 store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C187 store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C
188188
189 const state = useThreadSelectionStore.getState();189 const state = useThreadSelectionStore.getState();
190 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);190 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
191 expect(state.selectedThreadIds.size).toBe(1);191 expect(state.selectedThreadKeys.size).toBe(1);
192 });192 });
193193
194 it("preserves previously selected threads outside the range", () => {194 it("preserves previously selected threads outside the range", () => {
...@@ -200,11 +200,11 @@ describe("threadSelectionStore", () => {...@@ -200,11 +200,11 @@ describe("threadSelectionStore", () => {
200 store.rangeSelectTo(THREAD_D, ORDERED);200 store.rangeSelectTo(THREAD_D, ORDERED);
201201
202 const state = useThreadSelectionStore.getState();202 const state = useThreadSelectionStore.getState();
203 expect(state.selectedThreadIds.has(THREAD_A)).toBe(true);203 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true);
204 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);204 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
205 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);205 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
206 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);206 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
207 expect(state.selectedThreadIds.size).toBe(4);207 expect(state.selectedThreadKeys.size).toBe(4);
208 });208 });
209 });209 });
210210
...@@ -216,8 +216,8 @@ describe("threadSelectionStore", () => {...@@ -216,8 +216,8 @@ describe("threadSelectionStore", () => {
216 store.clearSelection();216 store.clearSelection();
217217
218 const state = useThreadSelectionStore.getState();218 const state = useThreadSelectionStore.getState();
219 expect(state.selectedThreadIds.size).toBe(0);219 expect(state.selectedThreadKeys.size).toBe(0);
220 expect(state.anchorThreadId).toBeNull();220 expect(state.anchorThreadKey).toBeNull();
221 });221 });
222222
223 it("is a no-op when already empty", () => {223 it("is a no-op when already empty", () => {
...@@ -226,7 +226,7 @@ describe("threadSelectionStore", () => {...@@ -226,7 +226,7 @@ describe("threadSelectionStore", () => {
226 const stateAfter = useThreadSelectionStore.getState();226 const stateAfter = useThreadSelectionStore.getState();
227227
228 // Should be referentially the same (no unnecessary re-render)228 // Should be referentially the same (no unnecessary re-render)
229 expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds);229 expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys);
230 });230 });
231 });231 });
232232
...@@ -239,8 +239,8 @@ describe("threadSelectionStore", () => {...@@ -239,8 +239,8 @@ describe("threadSelectionStore", () => {
239 store.removeFromSelection([THREAD_A, THREAD_C]);239 store.removeFromSelection([THREAD_A, THREAD_C]);
240240
241 const state = useThreadSelectionStore.getState();241 const state = useThreadSelectionStore.getState();
242 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);242 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
243 expect(state.selectedThreadIds.size).toBe(1);243 expect(state.selectedThreadKeys.size).toBe(1);
244 });244 });
245245
246 it("clears anchor when the anchor thread is removed", () => {246 it("clears anchor when the anchor thread is removed", () => {
...@@ -249,7 +249,7 @@ describe("threadSelectionStore", () => {...@@ -249,7 +249,7 @@ describe("threadSelectionStore", () => {
249 store.toggleThread(THREAD_B); // anchor = B249 store.toggleThread(THREAD_B); // anchor = B
250 store.removeFromSelection([THREAD_B]);250 store.removeFromSelection([THREAD_B]);
251251
252 expect(useThreadSelectionStore.getState().anchorThreadId).toBeNull();252 expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull();
253 });253 });
254254
255 it("preserves anchor when the anchor thread is not removed", () => {255 it("preserves anchor when the anchor thread is not removed", () => {
...@@ -258,7 +258,7 @@ describe("threadSelectionStore", () => {...@@ -258,7 +258,7 @@ describe("threadSelectionStore", () => {
258 store.toggleThread(THREAD_B); // anchor = B258 store.toggleThread(THREAD_B); // anchor = B
259 store.removeFromSelection([THREAD_A]);259 store.removeFromSelection([THREAD_A]);
260260
261 expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B);261 expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B);
262 });262 });
263263
264 it("is a no-op when none of the specified threads are selected", () => {264 it("is a no-op when none of the specified threads are selected", () => {
...@@ -268,7 +268,7 @@ describe("threadSelectionStore", () => {...@@ -268,7 +268,7 @@ describe("threadSelectionStore", () => {
268 store.removeFromSelection([THREAD_B, THREAD_C]);268 store.removeFromSelection([THREAD_B, THREAD_C]);
269 const stateAfter = useThreadSelectionStore.getState();269 const stateAfter = useThreadSelectionStore.getState();
270270
271 expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds);271 expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys);
272 });272 });
273 });273 });
274274
apps/web/src/threadSelectionStore.ts+51-53
...@@ -4,121 +4,119 @@...@@ -4,121 +4,119 @@
4 * Supports Cmd/Ctrl+Click (toggle individual), Shift+Click (range select),4 * Supports Cmd/Ctrl+Click (toggle individual), Shift+Click (range select),
5 * and bulk actions on the selected set.5 * and bulk actions on the selected set.
6 */6 */
7
8import type { ThreadId } from "@t3tools/contracts";
9import { create } from "zustand";7import { create } from "zustand";
108
11export interface ThreadSelectionState {9export interface ThreadSelectionState {
12 /** Currently selected thread IDs. */10 /** Currently selected scoped thread keys. */
13 selectedThreadIds: ReadonlySet<ThreadId>;11 selectedThreadKeys: ReadonlySet<string>;
14 /** The thread ID that anchors shift-click range selection. */12 /** The scoped thread key that anchors shift-click range selection. */
15 anchorThreadId: ThreadId | null;13 anchorThreadKey: string | null;
16}14}
1715
18interface ThreadSelectionStore extends ThreadSelectionState {16interface ThreadSelectionStore extends ThreadSelectionState {
19 /** Toggle a single thread in the selection (Cmd/Ctrl+Click). */17 /** Toggle a single scoped thread key in the selection (Cmd/Ctrl+Click). */
20 toggleThread: (threadId: ThreadId) => void;18 toggleThread: (threadKey: string) => void;
21 /**19 /**
22 * Select a range of threads (Shift+Click).20 * Select a range of threads (Shift+Click).
23 * Requires the ordered list of thread IDs within the same project21 * Requires the ordered list of scoped thread keys within the same project
24 * so the store can compute which threads fall between anchor and target.22 * so the store can compute which threads fall between anchor and target.
25 */23 */
26 rangeSelectTo: (threadId: ThreadId, orderedThreadIds: readonly ThreadId[]) => void;24 rangeSelectTo: (threadKey: string, orderedThreadKeys: readonly string[]) => void;
27 /** Clear all selection state. */25 /** Clear all selection state. */
28 clearSelection: () => void;26 clearSelection: () => void;
29 /** Remove specific thread IDs from the selection (e.g. after deletion). */27 /** Remove specific scoped thread keys from the selection (e.g. after deletion). */
30 removeFromSelection: (threadIds: readonly ThreadId[]) => void;28 removeFromSelection: (threadKeys: readonly string[]) => void;
31 /** Set the anchor thread without adding it to the selection (e.g. on plain-click navigate). */29 /** Set the anchor thread without adding it to the selection (e.g. on plain-click navigate). */
32 setAnchor: (threadId: ThreadId) => void;30 setAnchor: (threadKey: string) => void;
33 /** Check if any threads are selected. */31 /** Check if any threads are selected. */
34 hasSelection: () => boolean;32 hasSelection: () => boolean;
35}33}
3634
37const EMPTY_SET = new Set<ThreadId>();35const EMPTY_SET = new Set<string>();
3836
39export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({37export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({
40 selectedThreadIds: EMPTY_SET,38 selectedThreadKeys: EMPTY_SET,
41 anchorThreadId: null,39 anchorThreadKey: null,
4240
43 toggleThread: (threadId) => {41 toggleThread: (threadKey) => {
44 set((state) => {42 set((state) => {
45 const next = new Set(state.selectedThreadIds);43 const next = new Set(state.selectedThreadKeys);
46 if (next.has(threadId)) {44 if (next.has(threadKey)) {
47 next.delete(threadId);45 next.delete(threadKey);
48 } else {46 } else {
49 next.add(threadId);47 next.add(threadKey);
50 }48 }
51 return {49 return {
52 selectedThreadIds: next,50 selectedThreadKeys: next,
53 anchorThreadId: next.has(threadId) ? threadId : state.anchorThreadId,51 anchorThreadKey: next.has(threadKey) ? threadKey : state.anchorThreadKey,
54 };52 };
55 });53 });
56 },54 },
5755
58 rangeSelectTo: (threadId, orderedThreadIds) => {56 rangeSelectTo: (threadKey, orderedThreadKeys) => {
59 set((state) => {57 set((state) => {
60 const anchor = state.anchorThreadId;58 const anchor = state.anchorThreadKey;
61 if (anchor === null) {59 if (anchor === null) {
62 // No anchor yet — treat as a single toggle60 // No anchor yet — treat as a single toggle
63 const next = new Set(state.selectedThreadIds);61 const next = new Set(state.selectedThreadKeys);
64 next.add(threadId);62 next.add(threadKey);
65 return { selectedThreadIds: next, anchorThreadId: threadId };63 return { selectedThreadKeys: next, anchorThreadKey: threadKey };
66 }64 }
6765
68 const anchorIndex = orderedThreadIds.indexOf(anchor);66 const anchorIndex = orderedThreadKeys.indexOf(anchor);
69 const targetIndex = orderedThreadIds.indexOf(threadId);67 const targetIndex = orderedThreadKeys.indexOf(threadKey);
70 if (anchorIndex === -1 || targetIndex === -1) {68 if (anchorIndex === -1 || targetIndex === -1) {
71 // Anchor or target not in this list (different project?) — fallback to toggle69 // Anchor or target not in this list (different project?) — fallback to toggle
72 const next = new Set(state.selectedThreadIds);70 const next = new Set(state.selectedThreadKeys);
73 next.add(threadId);71 next.add(threadKey);
74 return { selectedThreadIds: next, anchorThreadId: threadId };72 return { selectedThreadKeys: next, anchorThreadKey: threadKey };
75 }73 }
7674
77 const start = Math.min(anchorIndex, targetIndex);75 const start = Math.min(anchorIndex, targetIndex);
78 const end = Math.max(anchorIndex, targetIndex);76 const end = Math.max(anchorIndex, targetIndex);
79 const next = new Set(state.selectedThreadIds);77 const next = new Set(state.selectedThreadKeys);
80 for (let i = start; i <= end; i++) {78 for (let i = start; i <= end; i++) {
81 const id = orderedThreadIds[i];79 const key = orderedThreadKeys[i];
82 if (id !== undefined) {80 if (key !== undefined) {
83 next.add(id);81 next.add(key);
84 }82 }
85 }83 }
86 // Keep anchor stable so subsequent shift-clicks extend from the same point84 // Keep anchor stable so subsequent shift-clicks extend from the same point
87 return { selectedThreadIds: next, anchorThreadId: anchor };85 return { selectedThreadKeys: next, anchorThreadKey: anchor };
88 });86 });
89 },87 },
9088
91 clearSelection: () => {89 clearSelection: () => {
92 const state = get();90 const state = get();
93 if (state.selectedThreadIds.size === 0 && state.anchorThreadId === null) return;91 if (state.selectedThreadKeys.size === 0 && state.anchorThreadKey === null) return;
94 set({ selectedThreadIds: EMPTY_SET, anchorThreadId: null });92 set({ selectedThreadKeys: EMPTY_SET, anchorThreadKey: null });
95 },93 },
9694
97 setAnchor: (threadId) => {95 setAnchor: (threadKey) => {
98 if (get().anchorThreadId === threadId) return;96 if (get().anchorThreadKey === threadKey) return;
99 set({ anchorThreadId: threadId });97 set({ anchorThreadKey: threadKey });
100 },98 },
10199
102 removeFromSelection: (threadIds) => {100 removeFromSelection: (threadKeys) => {
103 set((state) => {101 set((state) => {
104 const toRemove = new Set(threadIds);102 const toRemove = new Set(threadKeys);
105 let changed = false;103 let changed = false;
106 const next = new Set<ThreadId>();104 const next = new Set<string>();
107 for (const id of state.selectedThreadIds) {105 for (const key of state.selectedThreadKeys) {
108 if (toRemove.has(id)) {106 if (toRemove.has(key)) {
109 changed = true;107 changed = true;
110 } else {108 } else {
111 next.add(id);109 next.add(key);
112 }110 }
113 }111 }
114 if (!changed) return state;112 if (!changed) return state;
115 const newAnchor =113 const newAnchor =
116 state.anchorThreadId !== null && toRemove.has(state.anchorThreadId)114 state.anchorThreadKey !== null && toRemove.has(state.anchorThreadKey)
117 ? null115 ? null
118 : state.anchorThreadId;116 : state.anchorThreadKey;
119 return { selectedThreadIds: next, anchorThreadId: newAnchor };117 return { selectedThreadKeys: next, anchorThreadKey: newAnchor };
120 });118 });
121 },119 },
122120
123 hasSelection: () => get().selectedThreadIds.size > 0,121 hasSelection: () => get().selectedThreadKeys.size > 0,
124}));122}));
apps/web/src/types.ts+7
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import type {1import type {
2 EnvironmentId,
2 ModelSelection,3 ModelSelection,
3 OrchestrationLatestTurn,4 OrchestrationLatestTurn,
4 OrchestrationProposedPlanId,5 OrchestrationProposedPlanId,
6 RepositoryIdentity,
5 OrchestrationSessionStatus,7 OrchestrationSessionStatus,
6 OrchestrationThreadActivity,8 OrchestrationThreadActivity,
7 ProjectScript as ContractProjectScript,9 ProjectScript as ContractProjectScript,
...@@ -80,8 +82,10 @@ export interface TurnDiffSummary {...@@ -80,8 +82,10 @@ export interface TurnDiffSummary {
8082
81export interface Project {83export interface Project {
82 id: ProjectId;84 id: ProjectId;
85 environmentId: EnvironmentId;
83 name: string;86 name: string;
84 cwd: string;87 cwd: string;
88 repositoryIdentity?: RepositoryIdentity | null;
85 defaultModelSelection: ModelSelection | null;89 defaultModelSelection: ModelSelection | null;
86 createdAt?: string | undefined;90 createdAt?: string | undefined;
87 updatedAt?: string | undefined;91 updatedAt?: string | undefined;
...@@ -90,6 +94,7 @@ export interface Project {...@@ -90,6 +94,7 @@ export interface Project {
9094
91export interface Thread {95export interface Thread {
92 id: ThreadId;96 id: ThreadId;
97 environmentId: EnvironmentId;
93 codexThreadId: string | null;98 codexThreadId: string | null;
94 projectId: ProjectId;99 projectId: ProjectId;
95 title: string;100 title: string;
...@@ -113,6 +118,7 @@ export interface Thread {...@@ -113,6 +118,7 @@ export interface Thread {
113118
114export interface ThreadShell {119export interface ThreadShell {
115 id: ThreadId;120 id: ThreadId;
121 environmentId: EnvironmentId;
116 codexThreadId: string | null;122 codexThreadId: string | null;
117 projectId: ProjectId;123 projectId: ProjectId;
118 title: string;124 title: string;
...@@ -134,6 +140,7 @@ export interface ThreadTurnState {...@@ -134,6 +140,7 @@ export interface ThreadTurnState {
134140
135export interface SidebarThreadSummary {141export interface SidebarThreadSummary {
136 id: ThreadId;142 id: ThreadId;
143 environmentId: EnvironmentId;
137 projectId: ProjectId;144 projectId: ProjectId;
138 title: string;145 title: string;
139 interactionMode: ProviderInteractionMode;146 interactionMode: ProviderInteractionMode;
apps/web/src/uiStateStore.test.ts+11-11
...@@ -74,9 +74,9 @@ describe("uiStateStore pure functions", () => {...@@ -74,9 +74,9 @@ describe("uiStateStore pure functions", () => {
74 });74 });
7575
76 const next = syncProjects(initialState, [76 const next = syncProjects(initialState, [
77 { id: project1, cwd: "/tmp/project-1" },77 { key: project1, cwd: "/tmp/project-1" },
78 { id: project2, cwd: "/tmp/project-2" },78 { key: project2, cwd: "/tmp/project-2" },
79 { id: project3, cwd: "/tmp/project-3" },79 { key: project3, cwd: "/tmp/project-3" },
80 ]);80 ]);
8181
82 expect(next.projectOrder).toEqual([project2, project1, project3]);82 expect(next.projectOrder).toEqual([project2, project1, project3]);
...@@ -96,14 +96,14 @@ describe("uiStateStore pure functions", () => {...@@ -96,14 +96,14 @@ describe("uiStateStore pure functions", () => {
96 projectOrder: [oldProject2, oldProject1],96 projectOrder: [oldProject2, oldProject1],
97 }),97 }),
98 [98 [
99 { id: oldProject1, cwd: "/tmp/project-1" },99 { key: oldProject1, cwd: "/tmp/project-1" },
100 { id: oldProject2, cwd: "/tmp/project-2" },100 { key: oldProject2, cwd: "/tmp/project-2" },
101 ],101 ],
102 );102 );
103103
104 const next = syncProjects(initialState, [104 const next = syncProjects(initialState, [
105 { id: oldProject1, cwd: "/tmp/project-1" },105 { key: oldProject1, cwd: "/tmp/project-1" },
106 { id: recreatedProject2, cwd: "/tmp/project-2" },106 { key: recreatedProject2, cwd: "/tmp/project-2" },
107 ]);107 ]);
108108
109 expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]);109 expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]);
...@@ -119,10 +119,10 @@ describe("uiStateStore pure functions", () => {...@@ -119,10 +119,10 @@ describe("uiStateStore pure functions", () => {
119 },119 },
120 projectOrder: [project1],120 projectOrder: [project1],
121 }),121 }),
122 [{ id: project1, cwd: "/tmp/project-1" }],122 [{ key: project1, cwd: "/tmp/project-1" }],
123 );123 );
124124
125 const next = syncProjects(initialState, [{ id: project1, cwd: "/tmp/project-1-renamed" }]);125 const next = syncProjects(initialState, [{ key: project1, cwd: "/tmp/project-1-renamed" }]);
126126
127 expect(next).not.toBe(initialState);127 expect(next).not.toBe(initialState);
128 expect(next.projectOrder).toEqual([project1]);128 expect(next.projectOrder).toEqual([project1]);
...@@ -139,7 +139,7 @@ describe("uiStateStore pure functions", () => {...@@ -139,7 +139,7 @@ describe("uiStateStore pure functions", () => {
139 },139 },
140 });140 });
141141
142 const next = syncThreads(initialState, [{ id: thread1 }]);142 const next = syncThreads(initialState, [{ key: thread1 }]);
143143
144 expect(next.threadLastVisitedAtById).toEqual({144 expect(next.threadLastVisitedAtById).toEqual({
145 [thread1]: "2026-02-25T12:35:00.000Z",145 [thread1]: "2026-02-25T12:35:00.000Z",
...@@ -152,7 +152,7 @@ describe("uiStateStore pure functions", () => {...@@ -152,7 +152,7 @@ describe("uiStateStore pure functions", () => {
152152
153 const next = syncThreads(initialState, [153 const next = syncThreads(initialState, [
154 {154 {
155 id: thread1,155 key: thread1,
156 seedVisitedAt: "2026-02-25T12:35:00.000Z",156 seedVisitedAt: "2026-02-25T12:35:00.000Z",
157 },157 },
158 ]);158 ]);
apps/web/src/uiStateStore.ts+30-35
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { Debouncer } from "@tanstack/react-pacer";1import { Debouncer } from "@tanstack/react-pacer";
2import { type ProjectId, type ThreadId } from "@t3tools/contracts";
3import { create } from "zustand";2import { create } from "zustand";
43
5const PERSISTED_STATE_KEY = "t3code:ui-state:v1";4const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
...@@ -23,7 +22,7 @@ interface PersistedUiState {...@@ -23,7 +22,7 @@ interface PersistedUiState {
2322
24export interface UiProjectState {23export interface UiProjectState {
25 projectExpandedById: Record<string, boolean>;24 projectExpandedById: Record<string, boolean>;
26 projectOrder: ProjectId[];25 projectOrder: string[];
27}26}
2827
29export interface UiThreadState {28export interface UiThreadState {
...@@ -33,12 +32,12 @@ export interface UiThreadState {...@@ -33,12 +32,12 @@ export interface UiThreadState {
33export interface UiState extends UiProjectState, UiThreadState {}32export interface UiState extends UiProjectState, UiThreadState {}
3433
35export interface SyncProjectInput {34export interface SyncProjectInput {
36 id: ProjectId;35 key: string;
37 cwd: string;36 cwd: string;
38}37}
3938
40export interface SyncThreadInput {39export interface SyncThreadInput {
41 id: ThreadId;40 key: string;
42 seedVisitedAt?: string | undefined;41 seedVisitedAt?: string | undefined;
43}42}
4443
...@@ -50,7 +49,7 @@ const initialState: UiState = {...@@ -50,7 +49,7 @@ const initialState: UiState = {
5049
51const persistedExpandedProjectCwds = new Set<string>();50const persistedExpandedProjectCwds = new Set<string>();
52const persistedProjectOrderCwds: string[] = [];51const persistedProjectOrderCwds: string[] = [];
53const currentProjectCwdById = new Map<ProjectId, string>();52const currentProjectCwdById = new Map<string, string>();
54let legacyKeysCleanedUp = false;53let legacyKeysCleanedUp = false;
5554
56function readPersistedState(): UiState {55function readPersistedState(): UiState {
...@@ -100,7 +99,7 @@ function persistState(state: UiState): void {...@@ -100,7 +99,7 @@ function persistState(state: UiState): void {
100 const expandedProjectCwds = Object.entries(state.projectExpandedById)99 const expandedProjectCwds = Object.entries(state.projectExpandedById)
101 .filter(([, expanded]) => expanded)100 .filter(([, expanded]) => expanded)
102 .flatMap(([projectId]) => {101 .flatMap(([projectId]) => {
103 const cwd = currentProjectCwdById.get(projectId as ProjectId);102 const cwd = currentProjectCwdById.get(projectId);
104 return cwd ? [cwd] : [];103 return cwd ? [cwd] : [];
105 });104 });
106 const projectOrderCwds = state.projectOrder.flatMap((projectId) => {105 const projectOrderCwds = state.projectOrder.flatMap((projectId) => {
...@@ -141,7 +140,7 @@ function recordsEqual<T>(left: Record<string, T>, right: Record<string, T>): boo...@@ -141,7 +140,7 @@ function recordsEqual<T>(left: Record<string, T>, right: Record<string, T>): boo
141 return true;140 return true;
142}141}
143142
144function projectOrdersEqual(left: readonly ProjectId[], right: readonly ProjectId[]): boolean {143function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean {
145 return (144 return (
146 left.length === right.length && left.every((projectId, index) => projectId === right[index])145 left.length === right.length && left.every((projectId, index) => projectId === right[index])
147 );146 );
...@@ -154,11 +153,11 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput...@@ -154,11 +153,11 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
154 );153 );
155 currentProjectCwdById.clear();154 currentProjectCwdById.clear();
156 for (const project of projects) {155 for (const project of projects) {
157 currentProjectCwdById.set(project.id, project.cwd);156 currentProjectCwdById.set(project.key, project.cwd);
158 }157 }
159 const cwdMappingChanged =158 const cwdMappingChanged =
160 previousProjectCwdById.size !== currentProjectCwdById.size ||159 previousProjectCwdById.size !== currentProjectCwdById.size ||
161 projects.some((project) => previousProjectCwdById.get(project.id) !== project.cwd);160 projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd);
162161
163 const nextExpandedById: Record<string, boolean> = {};162 const nextExpandedById: Record<string, boolean> = {};
164 const previousExpandedById = state.projectExpandedById;163 const previousExpandedById = state.projectExpandedById;
...@@ -168,14 +167,14 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput...@@ -168,14 +167,14 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
168 const mappedProjects = projects.map((project, index) => {167 const mappedProjects = projects.map((project, index) => {
169 const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd);168 const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd);
170 const expanded =169 const expanded =
171 previousExpandedById[project.id] ??170 previousExpandedById[project.key] ??
172 (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ??171 (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ??
173 (persistedExpandedProjectCwds.size > 0172 (persistedExpandedProjectCwds.size > 0
174 ? persistedExpandedProjectCwds.has(project.cwd)173 ? persistedExpandedProjectCwds.has(project.cwd)
175 : true);174 : true);
176 nextExpandedById[project.id] = expanded;175 nextExpandedById[project.key] = expanded;
177 return {176 return {
178 id: project.id,177 id: project.key,
179 cwd: project.cwd,178 cwd: project.cwd,
180 incomingIndex: index,179 incomingIndex: index,
181 };180 };
...@@ -187,8 +186,8 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput...@@ -187,8 +186,8 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
187 const nextProjectIdByCwd = new Map(186 const nextProjectIdByCwd = new Map(
188 mappedProjects.map((project) => [project.cwd, project.id] as const),187 mappedProjects.map((project) => [project.cwd, project.id] as const),
189 );188 );
190 const usedProjectIds = new Set<ProjectId>();189 const usedProjectIds = new Set<string>();
191 const orderedProjectIds: ProjectId[] = [];190 const orderedProjectIds: string[] = [];
192191
193 for (const projectId of state.projectOrder) {192 for (const projectId of state.projectOrder) {
194 const matchedProjectId =193 const matchedProjectId =
...@@ -246,19 +245,19 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput...@@ -246,19 +245,19 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
246}245}
247246
248export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState {247export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState {
249 const retainedThreadIds = new Set(threads.map((thread) => thread.id));248 const retainedThreadIds = new Set(threads.map((thread) => thread.key));
250 const nextThreadLastVisitedAtById = Object.fromEntries(249 const nextThreadLastVisitedAtById = Object.fromEntries(
251 Object.entries(state.threadLastVisitedAtById).filter(([threadId]) =>250 Object.entries(state.threadLastVisitedAtById).filter(([threadId]) =>
252 retainedThreadIds.has(threadId as ThreadId),251 retainedThreadIds.has(threadId),
253 ),252 ),
254 );253 );
255 for (const thread of threads) {254 for (const thread of threads) {
256 if (255 if (
257 nextThreadLastVisitedAtById[thread.id] === undefined &&256 nextThreadLastVisitedAtById[thread.key] === undefined &&
258 thread.seedVisitedAt !== undefined &&257 thread.seedVisitedAt !== undefined &&
259 thread.seedVisitedAt.length > 0258 thread.seedVisitedAt.length > 0
260 ) {259 ) {
261 nextThreadLastVisitedAtById[thread.id] = thread.seedVisitedAt;260 nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt;
262 }261 }
263 }262 }
264 if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) {263 if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) {
...@@ -270,7 +269,7 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[])...@@ -270,7 +269,7 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[])
270 };269 };
271}270}
272271
273export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt?: string): UiState {272export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState {
274 const at = visitedAt ?? new Date().toISOString();273 const at = visitedAt ?? new Date().toISOString();
275 const visitedAtMs = Date.parse(at);274 const visitedAtMs = Date.parse(at);
276 const previousVisitedAt = state.threadLastVisitedAtById[threadId];275 const previousVisitedAt = state.threadLastVisitedAtById[threadId];
...@@ -293,7 +292,7 @@ export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt?...@@ -293,7 +292,7 @@ export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt?
293292
294export function markThreadUnread(293export function markThreadUnread(
295 state: UiState,294 state: UiState,
296 threadId: ThreadId,295 threadId: string,
297 latestTurnCompletedAt: string | null | undefined,296 latestTurnCompletedAt: string | null | undefined,
298): UiState {297): UiState {
299 if (!latestTurnCompletedAt) {298 if (!latestTurnCompletedAt) {
...@@ -316,7 +315,7 @@ export function markThreadUnread(...@@ -316,7 +315,7 @@ export function markThreadUnread(
316 };315 };
317}316}
318317
319export function clearThreadUi(state: UiState, threadId: ThreadId): UiState {318export function clearThreadUi(state: UiState, threadId: string): UiState {
320 if (!(threadId in state.threadLastVisitedAtById)) {319 if (!(threadId in state.threadLastVisitedAtById)) {
321 return state;320 return state;
322 }321 }
...@@ -328,7 +327,7 @@ export function clearThreadUi(state: UiState, threadId: ThreadId): UiState {...@@ -328,7 +327,7 @@ export function clearThreadUi(state: UiState, threadId: ThreadId): UiState {
328 };327 };
329}328}
330329
331export function toggleProject(state: UiState, projectId: ProjectId): UiState {330export function toggleProject(state: UiState, projectId: string): UiState {
332 const expanded = state.projectExpandedById[projectId] ?? true;331 const expanded = state.projectExpandedById[projectId] ?? true;
333 return {332 return {
334 ...state,333 ...state,
...@@ -339,11 +338,7 @@ export function toggleProject(state: UiState, projectId: ProjectId): UiState {...@@ -339,11 +338,7 @@ export function toggleProject(state: UiState, projectId: ProjectId): UiState {
339 };338 };
340}339}
341340
342export function setProjectExpanded(341export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState {
343 state: UiState,
344 projectId: ProjectId,
345 expanded: boolean,
346): UiState {
347 if ((state.projectExpandedById[projectId] ?? true) === expanded) {342 if ((state.projectExpandedById[projectId] ?? true) === expanded) {
348 return state;343 return state;
349 }344 }
...@@ -358,8 +353,8 @@ export function setProjectExpanded(...@@ -358,8 +353,8 @@ export function setProjectExpanded(
358353
359export function reorderProjects(354export function reorderProjects(
360 state: UiState,355 state: UiState,
361 draggedProjectId: ProjectId,356 draggedProjectId: string,
362 targetProjectId: ProjectId,357 targetProjectId: string,
363): UiState {358): UiState {
364 if (draggedProjectId === targetProjectId) {359 if (draggedProjectId === targetProjectId) {
365 return state;360 return state;
...@@ -384,12 +379,12 @@ export function reorderProjects(...@@ -384,12 +379,12 @@ export function reorderProjects(
384interface UiStateStore extends UiState {379interface UiStateStore extends UiState {
385 syncProjects: (projects: readonly SyncProjectInput[]) => void;380 syncProjects: (projects: readonly SyncProjectInput[]) => void;
386 syncThreads: (threads: readonly SyncThreadInput[]) => void;381 syncThreads: (threads: readonly SyncThreadInput[]) => void;
387 markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void;382 markThreadVisited: (threadId: string, visitedAt?: string) => void;
388 markThreadUnread: (threadId: ThreadId, latestTurnCompletedAt: string | null | undefined) => void;383 markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
389 clearThreadUi: (threadId: ThreadId) => void;384 clearThreadUi: (threadId: string) => void;
390 toggleProject: (projectId: ProjectId) => void;385 toggleProject: (projectId: string) => void;
391 setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void;386 setProjectExpanded: (projectId: string, expanded: boolean) => void;
392 reorderProjects: (draggedProjectId: ProjectId, targetProjectId: ProjectId) => void;387 reorderProjects: (draggedProjectId: string, targetProjectId: string) => void;
393}388}
394389
395export const useUiStateStore = create<UiStateStore>((set) => ({390export const useUiStateStore = create<UiStateStore>((set) => ({
apps/web/src/vite-env.d.ts+2-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1/// <reference types="vite/client" />1/// <reference types="vite/client" />
22
3import type { NativeApi, DesktopBridge } from "@t3tools/contracts";3import type { DesktopBridge, LocalApi } from "@t3tools/contracts";
44
5interface ImportMetaEnv {5interface ImportMetaEnv {
6 readonly APP_VERSION: string;6 readonly APP_VERSION: string;
...@@ -12,7 +12,7 @@ interface ImportMeta {...@@ -12,7 +12,7 @@ interface ImportMeta {
1212
13declare global {13declare global {
14 interface Window {14 interface Window {
15 nativeApi?: NativeApi;15 nativeApi?: LocalApi;
16 desktopBridge?: DesktopBridge;16 desktopBridge?: DesktopBridge;
17 }17 }
18}18}
apps/web/src/worktreeCleanup.test.ts+4-1
...@@ -1,12 +1,15 @@...@@ -1,12 +1,15 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";4import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
5import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";5import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";
66
7const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
8
7function makeThread(overrides: Partial<Thread> = {}): Thread {9function makeThread(overrides: Partial<Thread> = {}): Thread {
8 return {10 return {
9 id: ThreadId.makeUnsafe("thread-1"),11 id: ThreadId.makeUnsafe("thread-1"),
12 environmentId: localEnvironmentId,
10 codexThreadId: null,13 codexThreadId: null,
11 projectId: ProjectId.makeUnsafe("project-1"),14 projectId: ProjectId.makeUnsafe("project-1"),
12 title: "Thread",15 title: "Thread",
apps/web/src/wsNativeApi.test.ts deleted-417
...@@ -1,417 +0,0 @@
1import {
2 CommandId,
3 DEFAULT_SERVER_SETTINGS,
4 type DesktopBridge,
5 EventId,
6 type GitStatusResult,
7 ProjectId,
8 type OrchestrationEvent,
9 type ServerConfig,
10 type ServerProvider,
11 type TerminalEvent,
12 ThreadId,
13} from "@t3tools/contracts";
14import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
16import type { ContextMenuItem } from "@t3tools/contracts";
17
18const showContextMenuFallbackMock =
19 vi.fn<
20 <T extends string>(
21 items: readonly ContextMenuItem<T>[],
22 position?: { x: number; y: number },
23 ) => Promise<T | null>
24 >();
25
26function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
27 listeners.add(listener);
28 return () => {
29 listeners.delete(listener);
30 };
31}
32
33const terminalEventListeners = new Set<(event: TerminalEvent) => void>();
34const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>();
35const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
36
37const rpcClientMock = {
38 dispose: vi.fn(),
39 terminal: {
40 open: vi.fn(),
41 write: vi.fn(),
42 resize: vi.fn(),
43 clear: vi.fn(),
44 restart: vi.fn(),
45 close: vi.fn(),
46 onEvent: vi.fn((listener: (event: TerminalEvent) => void) =>
47 registerListener(terminalEventListeners, listener),
48 ),
49 },
50 projects: {
51 searchEntries: vi.fn(),
52 writeFile: vi.fn(),
53 },
54 shell: {
55 openInEditor: vi.fn(),
56 },
57 git: {
58 pull: vi.fn(),
59 refreshStatus: vi.fn(),
60 onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) =>
61 registerListener(gitStatusListeners, listener),
62 ),
63 runStackedAction: vi.fn(),
64 listBranches: vi.fn(),
65 createWorktree: vi.fn(),
66 removeWorktree: vi.fn(),
67 createBranch: vi.fn(),
68 checkout: vi.fn(),
69 init: vi.fn(),
70 resolvePullRequest: vi.fn(),
71 preparePullRequestThread: vi.fn(),
72 },
73 server: {
74 getConfig: vi.fn(),
75 refreshProviders: vi.fn(),
76 upsertKeybinding: vi.fn(),
77 getSettings: vi.fn(),
78 updateSettings: vi.fn(),
79 subscribeConfig: vi.fn(),
80 subscribeLifecycle: vi.fn(),
81 },
82 orchestration: {
83 getSnapshot: vi.fn(),
84 dispatchCommand: vi.fn(),
85 getTurnDiff: vi.fn(),
86 getFullThreadDiff: vi.fn(),
87 replayEvents: vi.fn(),
88 onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) =>
89 registerListener(orchestrationEventListeners, listener),
90 ),
91 },
92};
93
94vi.mock("./wsRpcClient", () => {
95 return {
96 getWsRpcClient: () => rpcClientMock,
97 __resetWsRpcClientForTests: vi.fn(),
98 };
99});
100
101vi.mock("./contextMenuFallback", () => ({
102 showContextMenuFallback: showContextMenuFallbackMock,
103}));
104
105function emitEvent<T>(listeners: Set<(event: T) => void>, event: T) {
106 for (const listener of listeners) {
107 listener(event);
108 }
109}
110
111function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } {
112 const testGlobal = globalThis as typeof globalThis & {
113 window?: Window & typeof globalThis & { desktopBridge?: unknown };
114 };
115 if (!testGlobal.window) {
116 testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown };
117 }
118 return testGlobal.window;
119}
120
121function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {
122 return {
123 getWsUrl: () => null,
124 pickFolder: async () => null,
125 confirm: async () => true,
126 setTheme: async () => undefined,
127 showContextMenu: async () => null,
128 openExternal: async () => true,
129 onMenuAction: () => () => undefined,
130 getUpdateState: async () => {
131 throw new Error("getUpdateState not implemented in test");
132 },
133 checkForUpdate: async () => {
134 throw new Error("checkForUpdate not implemented in test");
135 },
136 downloadUpdate: async () => {
137 throw new Error("downloadUpdate not implemented in test");
138 },
139 installUpdate: async () => {
140 throw new Error("installUpdate not implemented in test");
141 },
142 onUpdateState: () => () => undefined,
143 ...overrides,
144 };
145}
146
147const defaultProviders: ReadonlyArray<ServerProvider> = [
148 {
149 provider: "codex",
150 enabled: true,
151 installed: true,
152 version: "0.116.0",
153 status: "ready",
154 auth: { status: "authenticated" },
155 checkedAt: "2026-01-01T00:00:00.000Z",
156 models: [],
157 },
158];
159
160const baseServerConfig: ServerConfig = {
161 cwd: "/tmp/workspace",
162 keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
163 keybindings: [],
164 issues: [],
165 providers: defaultProviders,
166 availableEditors: ["cursor"],
167 observability: {
168 logsDirectoryPath: "/tmp/workspace/.config/logs",
169 localTracingEnabled: true,
170 otlpTracesEnabled: false,
171 otlpMetricsEnabled: false,
172 },
173 settings: DEFAULT_SERVER_SETTINGS,
174};
175
176const baseGitStatus: GitStatusResult = {
177 isRepo: true,
178 hasOriginRemote: true,
179 isDefaultBranch: false,
180 branch: "feature/streamed",
181 hasWorkingTreeChanges: false,
182 workingTree: { files: [], insertions: 0, deletions: 0 },
183 hasUpstream: true,
184 aheadCount: 0,
185 behindCount: 0,
186 pr: null,
187};
188
189beforeEach(() => {
190 vi.resetModules();
191 vi.clearAllMocks();
192 showContextMenuFallbackMock.mockReset();
193 terminalEventListeners.clear();
194 orchestrationEventListeners.clear();
195 gitStatusListeners.clear();
196 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
197});
198
199afterEach(() => {
200 vi.restoreAllMocks();
201});
202
203describe("wsNativeApi", () => {
204 it("forwards server config fetches directly to the RPC client", async () => {
205 rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig);
206 const { createWsNativeApi } = await import("./wsNativeApi");
207
208 const api = createWsNativeApi();
209
210 await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig);
211 expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith();
212 expect(rpcClientMock.server.subscribeConfig).not.toHaveBeenCalled();
213 expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled();
214 });
215
216 it("forwards terminal and orchestration stream events", async () => {
217 const { createWsNativeApi } = await import("./wsNativeApi");
218
219 const api = createWsNativeApi();
220 const onTerminalEvent = vi.fn();
221 const onDomainEvent = vi.fn();
222
223 api.terminal.onEvent(onTerminalEvent);
224 api.orchestration.onDomainEvent(onDomainEvent);
225
226 const terminalEvent = {
227 threadId: "thread-1",
228 terminalId: "terminal-1",
229 createdAt: "2026-02-24T00:00:00.000Z",
230 type: "output",
231 data: "hello",
232 } as const;
233 emitEvent(terminalEventListeners, terminalEvent);
234
235 const orchestrationEvent = {
236 sequence: 1,
237 eventId: EventId.makeUnsafe("event-1"),
238 aggregateKind: "project",
239 aggregateId: ProjectId.makeUnsafe("project-1"),
240 occurredAt: "2026-02-24T00:00:00.000Z",
241 commandId: null,
242 causationEventId: null,
243 correlationId: null,
244 metadata: {},
245 type: "project.created",
246 payload: {
247 projectId: ProjectId.makeUnsafe("project-1"),
248 title: "Project",
249 workspaceRoot: "/tmp/workspace",
250 defaultModelSelection: {
251 provider: "codex",
252 model: "gpt-5-codex",
253 },
254 scripts: [],
255 createdAt: "2026-02-24T00:00:00.000Z",
256 updatedAt: "2026-02-24T00:00:00.000Z",
257 },
258 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>;
259 emitEvent(orchestrationEventListeners, orchestrationEvent);
260
261 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);
262 expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);
263 });
264
265 it("forwards git status stream events", async () => {
266 const { createWsNativeApi } = await import("./wsNativeApi");
267
268 const api = createWsNativeApi();
269 const onStatus = vi.fn();
270
271 api.git.onStatus({ cwd: "/repo" }, onStatus);
272
273 const gitStatus = baseGitStatus;
274 emitEvent(gitStatusListeners, gitStatus);
275
276 expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined);
277 expect(onStatus).toHaveBeenCalledWith(gitStatus);
278 });
279
280 it("forwards git status refreshes directly to the RPC client", async () => {
281 rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus);
282 const { createWsNativeApi } = await import("./wsNativeApi");
283
284 const api = createWsNativeApi();
285
286 await api.git.refreshStatus({ cwd: "/repo" });
287
288 expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
289 });
290
291 it("forwards orchestration stream subscription options to the RPC client", async () => {
292 const { createWsNativeApi } = await import("./wsNativeApi");
293
294 const api = createWsNativeApi();
295 const onDomainEvent = vi.fn();
296 const onResubscribe = vi.fn();
297
298 api.orchestration.onDomainEvent(onDomainEvent, { onResubscribe });
299
300 expect(rpcClientMock.orchestration.onDomainEvent).toHaveBeenCalledWith(onDomainEvent, {
301 onResubscribe,
302 });
303 });
304
305 it("sends orchestration dispatch commands as the direct RPC payload", async () => {
306 rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 });
307 const { createWsNativeApi } = await import("./wsNativeApi");
308
309 const api = createWsNativeApi();
310 const command = {
311 type: "project.create",
312 commandId: CommandId.makeUnsafe("cmd-1"),
313 projectId: ProjectId.makeUnsafe("project-1"),
314 title: "Project",
315 workspaceRoot: "/tmp/project",
316 defaultModelSelection: {
317 provider: "codex",
318 model: "gpt-5-codex",
319 },
320 createdAt: "2026-02-24T00:00:00.000Z",
321 } as const;
322 await api.orchestration.dispatchCommand(command);
323
324 expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command);
325 });
326
327 it("forwards workspace file writes to the project RPC", async () => {
328 rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" });
329 const { createWsNativeApi } = await import("./wsNativeApi");
330
331 const api = createWsNativeApi();
332 await api.projects.writeFile({
333 cwd: "/tmp/project",
334 relativePath: "plan.md",
335 contents: "# Plan\n",
336 });
337
338 expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({
339 cwd: "/tmp/project",
340 relativePath: "plan.md",
341 contents: "# Plan\n",
342 });
343 });
344
345 it("forwards full-thread diff requests to the orchestration RPC", async () => {
346 rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" });
347 const { createWsNativeApi } = await import("./wsNativeApi");
348
349 const api = createWsNativeApi();
350 await api.orchestration.getFullThreadDiff({
351 threadId: ThreadId.makeUnsafe("thread-1"),
352 toTurnCount: 1,
353 });
354
355 expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({
356 threadId: "thread-1",
357 toTurnCount: 1,
358 });
359 });
360
361 it("forwards provider refreshes directly to the RPC client", async () => {
362 const nextProviders: ReadonlyArray<ServerProvider> = [
363 {
364 ...defaultProviders[0]!,
365 checkedAt: "2026-01-03T00:00:00.000Z",
366 },
367 ];
368 rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders });
369 const { createWsNativeApi } = await import("./wsNativeApi");
370
371 const api = createWsNativeApi();
372
373 await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders });
374 expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();
375 });
376
377 it("forwards server settings updates directly to the RPC client", async () => {
378 const nextSettings = {
379 ...DEFAULT_SERVER_SETTINGS,
380 enableAssistantStreaming: true,
381 };
382 rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings);
383 const { createWsNativeApi } = await import("./wsNativeApi");
384
385 const api = createWsNativeApi();
386
387 await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual(
388 nextSettings,
389 );
390 expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({
391 enableAssistantStreaming: true,
392 });
393 });
394
395 it("forwards context menu metadata to the desktop bridge", async () => {
396 const showContextMenu = vi.fn().mockResolvedValue("delete");
397 getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu });
398
399 const { createWsNativeApi } = await import("./wsNativeApi");
400 const api = createWsNativeApi();
401 const items = [{ id: "delete", label: "Delete" }] as const;
402
403 await expect(api.contextMenu.show(items)).resolves.toBe("delete");
404 expect(showContextMenu).toHaveBeenCalledWith(items, undefined);
405 });
406
407 it("falls back to the browser context menu helper when the desktop bridge is missing", async () => {
408 showContextMenuFallbackMock.mockResolvedValue("rename");
409 const { createWsNativeApi } = await import("./wsNativeApi");
410
411 const api = createWsNativeApi();
412 const items = [{ id: "rename", label: "Rename" }] as const;
413
414 await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename");
415 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });
416 });
417});
apps/web/src/wsNativeApi.ts deleted-117
...@@ -1,117 +0,0 @@
1import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts";
2
3import { resetGitStatusStateForTests } from "./lib/gitStatusState";
4import { showContextMenuFallback } from "./contextMenuFallback";
5import { __resetWsRpcAtomClientForTests } from "./rpc/client";
6import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
7import { resetServerStateForTests } from "./rpc/serverState";
8import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";
9import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient";
10
11let instance: { api: NativeApi } | null = null;
12
13export async function __resetWsNativeApiForTests() {
14 instance = null;
15 await __resetWsRpcAtomClientForTests();
16 await __resetWsRpcClientForTests();
17 resetGitStatusStateForTests();
18 resetRequestLatencyStateForTests();
19 resetServerStateForTests();
20 resetWsConnectionStateForTests();
21}
22
23export function createWsNativeApi(): NativeApi {
24 if (instance) {
25 return instance.api;
26 }
27
28 const rpcClient = getWsRpcClient();
29
30 const api: NativeApi = {
31 dialogs: {
32 pickFolder: async () => {
33 if (!window.desktopBridge) return null;
34 return window.desktopBridge.pickFolder();
35 },
36 confirm: async (message) => {
37 if (window.desktopBridge) {
38 return window.desktopBridge.confirm(message);
39 }
40 return window.confirm(message);
41 },
42 },
43 terminal: {
44 open: (input) => rpcClient.terminal.open(input as never),
45 write: (input) => rpcClient.terminal.write(input as never),
46 resize: (input) => rpcClient.terminal.resize(input as never),
47 clear: (input) => rpcClient.terminal.clear(input as never),
48 restart: (input) => rpcClient.terminal.restart(input as never),
49 close: (input) => rpcClient.terminal.close(input as never),
50 onEvent: (callback) => rpcClient.terminal.onEvent(callback),
51 },
52 projects: {
53 searchEntries: rpcClient.projects.searchEntries,
54 writeFile: rpcClient.projects.writeFile,
55 },
56 shell: {
57 openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }),
58 openExternal: async (url) => {
59 if (window.desktopBridge) {
60 const opened = await window.desktopBridge.openExternal(url);
61 if (!opened) {
62 throw new Error("Unable to open link.");
63 }
64 return;
65 }
66
67 window.open(url, "_blank", "noopener,noreferrer");
68 },
69 },
70 git: {
71 pull: rpcClient.git.pull,
72 refreshStatus: rpcClient.git.refreshStatus,
73 onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options),
74 listBranches: rpcClient.git.listBranches,
75 createWorktree: rpcClient.git.createWorktree,
76 removeWorktree: rpcClient.git.removeWorktree,
77 createBranch: rpcClient.git.createBranch,
78 checkout: rpcClient.git.checkout,
79 init: rpcClient.git.init,
80 resolvePullRequest: rpcClient.git.resolvePullRequest,
81 preparePullRequestThread: rpcClient.git.preparePullRequestThread,
82 },
83 contextMenu: {
84 show: async <T extends string>(
85 items: readonly ContextMenuItem<T>[],
86 position?: { x: number; y: number },
87 ): Promise<T | null> => {
88 if (window.desktopBridge) {
89 return window.desktopBridge.showContextMenu(items, position) as Promise<T | null>;
90 }
91 return showContextMenuFallback(items, position);
92 },
93 },
94 server: {
95 getConfig: rpcClient.server.getConfig,
96 refreshProviders: rpcClient.server.refreshProviders,
97 upsertKeybinding: rpcClient.server.upsertKeybinding,
98 getSettings: rpcClient.server.getSettings,
99 updateSettings: rpcClient.server.updateSettings,
100 },
101 orchestration: {
102 getSnapshot: rpcClient.orchestration.getSnapshot,
103 dispatchCommand: rpcClient.orchestration.dispatchCommand,
104 getTurnDiff: rpcClient.orchestration.getTurnDiff,
105 getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff,
106 replayEvents: (fromSequenceExclusive) =>
107 rpcClient.orchestration
108 .replayEvents({ fromSequenceExclusive })
109 .then((events) => [...events]),
110 onDomainEvent: (callback, options) =>
111 rpcClient.orchestration.onDomainEvent(callback, options),
112 },
113 };
114
115 instance = { api };
116 return api;
117}
apps/web/src/wsRpcClient.test.ts+38-2
...@@ -3,9 +3,25 @@ import type {...@@ -3,9 +3,25 @@ import type {
3 GitStatusRemoteResult,3 GitStatusRemoteResult,
4 GitStatusStreamEvent,4 GitStatusStreamEvent,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { describe, expect, it, vi } from "vitest";6import { EnvironmentId } from "@t3tools/contracts";
7import { afterEach, describe, expect, it, vi } from "vitest";
78
8import { createWsRpcClient } from "./wsRpcClient";9vi.mock("./wsTransport", () => ({
10 WsTransport: class WsTransport {
11 dispose = vi.fn(async () => undefined);
12 reconnect = vi.fn(async () => undefined);
13 request = vi.fn();
14 requestStream = vi.fn();
15 subscribe = vi.fn(() => () => undefined);
16 },
17}));
18
19import {
20 __resetWsRpcClientForTests,
21 createWsRpcClient,
22 ensureWsRpcClientEntryForKnownEnvironment,
23 readWsRpcClientEntryForEnvironment,
24} from "./wsRpcClient";
9import { type WsTransport } from "./wsTransport";25import { type WsTransport } from "./wsTransport";
1026
11const baseLocalStatus: GitStatusLocalResult = {27const baseLocalStatus: GitStatusLocalResult = {
...@@ -25,6 +41,10 @@ const baseRemoteStatus: GitStatusRemoteResult = {...@@ -25,6 +41,10 @@ const baseRemoteStatus: GitStatusRemoteResult = {
25};41};
2642
27describe("wsRpcClient", () => {43describe("wsRpcClient", () => {
44 afterEach(async () => {
45 await __resetWsRpcClientForTests();
46 });
47
28 it("reduces git status stream events into flat status snapshots", () => {48 it("reduces git status stream events into flat status snapshots", () => {
29 const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => {49 const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => {
30 for (const event of [50 for (const event of [
...@@ -91,4 +111,20 @@ describe("wsRpcClient", () => {...@@ -91,4 +111,20 @@ describe("wsRpcClient", () => {
91 ],111 ],
92 ]);112 ]);
93 });113 });
114
115 it("does not fall back to the only registered client for an unbound environment", () => {
116 ensureWsRpcClientEntryForKnownEnvironment({
117 id: "known-env-a",
118 label: "Environment A",
119 source: "manual",
120 target: {
121 type: "ws",
122 wsUrl: "ws://localhost:3000",
123 },
124 });
125
126 expect(
127 readWsRpcClientEntryForEnvironment(EnvironmentId.makeUnsafe("environment-b")),
128 ).toBeNull();
129 });
94});130});
apps/web/src/wsRpcClient.ts+153-12
...@@ -4,14 +4,20 @@ import {...@@ -4,14 +4,20 @@ import {
4 type GitRunStackedActionResult,4 type GitRunStackedActionResult,
5 type GitStatusResult,5 type GitStatusResult,
6 type GitStatusStreamEvent,6 type GitStatusStreamEvent,
7 type NativeApi,7 type LocalApi,
8 ORCHESTRATION_WS_METHODS,8 ORCHESTRATION_WS_METHODS,
9 type EnvironmentId,
9 type ServerSettingsPatch,10 type ServerSettingsPatch,
10 WS_METHODS,11 WS_METHODS,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { getKnownEnvironmentBaseUrl, type KnownEnvironment } from "@t3tools/client-runtime";
12import { applyGitStatusStreamEvent } from "@t3tools/shared/git";14import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
13import { Effect, Stream } from "effect";15import { Effect, Stream } from "effect";
1416
17import {
18 getPrimaryKnownEnvironment,
19 resolvePrimaryEnvironmentBootstrapUrl,
20} from "./environmentBootstrap";
15import { type WsRpcProtocolClient } from "./rpc/protocol";21import { type WsRpcProtocolClient } from "./rpc/protocol";
16import { resetWsReconnectBackoff } from "./rpc/wsConnectionState";22import { resetWsReconnectBackoff } from "./rpc/wsConnectionState";
17import { WsTransport } from "./wsTransport";23import { WsTransport } from "./wsTransport";
...@@ -61,9 +67,9 @@ export interface WsRpcClient {...@@ -61,9 +67,9 @@ export interface WsRpcClient {
61 };67 };
62 readonly shell: {68 readonly shell: {
63 readonly openInEditor: (input: {69 readonly openInEditor: (input: {
64 readonly cwd: Parameters<NativeApi["shell"]["openInEditor"]>[0];70 readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0];
65 readonly editor: Parameters<NativeApi["shell"]["openInEditor"]>[1];71 readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1];
66 }) => ReturnType<NativeApi["shell"]["openInEditor"]>;72 }) => ReturnType<LocalApi["shell"]["openInEditor"]>;
67 };73 };
68 readonly git: {74 readonly git: {
69 readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>;75 readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>;
...@@ -109,22 +115,157 @@ export interface WsRpcClient {...@@ -109,22 +115,157 @@ export interface WsRpcClient {
109 };115 };
110}116}
111117
112let sharedWsRpcClient: WsRpcClient | null = null;118export interface WsRpcClientEntry {
119 readonly key: string;
120 readonly knownEnvironment: KnownEnvironment;
121 readonly client: WsRpcClient;
122 readonly environmentId: EnvironmentId | null;
123}
124
125type MutableWsRpcClientEntry = {
126 key: string;
127 knownEnvironment: KnownEnvironment;
128 client: WsRpcClient;
129 environmentId: EnvironmentId | null;
130};
131
132const wsRpcClientEntriesByKey = new Map<string, MutableWsRpcClientEntry>();
133const wsRpcClientKeyByEnvironmentId = new Map<EnvironmentId, string>();
134const wsRpcClientRegistryListeners = new Set<() => void>();
135
136function emitWsRpcClientRegistryChange() {
137 for (const listener of wsRpcClientRegistryListeners) {
138 listener();
139 }
140}
141
142function toReadonlyEntry(entry: MutableWsRpcClientEntry): WsRpcClientEntry {
143 return entry;
144}
145
146function createWsRpcClientEntry(knownEnvironment: KnownEnvironment): MutableWsRpcClientEntry {
147 const baseUrl = getKnownEnvironmentBaseUrl(knownEnvironment);
148 if (!baseUrl) {
149 throw new Error(`Unable to resolve websocket bootstrap URL for ${knownEnvironment.label}.`);
150 }
151
152 return {
153 key: knownEnvironment.id,
154 knownEnvironment,
155 client: createWsRpcClient(new WsTransport(baseUrl)),
156 environmentId: knownEnvironment.environmentId ?? null,
157 };
158}
159
160export function subscribeWsRpcClientRegistry(listener: () => void): () => void {
161 wsRpcClientRegistryListeners.add(listener);
162 return () => {
163 wsRpcClientRegistryListeners.delete(listener);
164 };
165}
166
167export function listWsRpcClientEntries(): ReadonlyArray<WsRpcClientEntry> {
168 return [...wsRpcClientEntriesByKey.values()].map(toReadonlyEntry);
169}
170
171export function ensureWsRpcClientEntryForKnownEnvironment(
172 knownEnvironment: KnownEnvironment,
173): WsRpcClientEntry {
174 const existingEntry = wsRpcClientEntriesByKey.get(knownEnvironment.id);
175 if (existingEntry) {
176 return toReadonlyEntry(existingEntry);
177 }
178
179 const entry = createWsRpcClientEntry(knownEnvironment);
180 wsRpcClientEntriesByKey.set(entry.key, entry);
181 if (entry.environmentId) {
182 wsRpcClientKeyByEnvironmentId.set(entry.environmentId, entry.key);
183 }
184 emitWsRpcClientRegistryChange();
185 return toReadonlyEntry(entry);
186}
187
188export function getPrimaryWsRpcClientEntry(): WsRpcClientEntry {
189 const primaryKnownEnvironment = getPrimaryKnownEnvironment();
190 if (!primaryKnownEnvironment) {
191 throw new Error("Unable to resolve the primary websocket environment.");
192 }
193
194 return ensureWsRpcClientEntryForKnownEnvironment(primaryKnownEnvironment);
195}
113196
114export function getWsRpcClient(): WsRpcClient {197export function getWsRpcClient(): WsRpcClient {
115 if (sharedWsRpcClient) {198 return getPrimaryWsRpcClientEntry().client;
116 return sharedWsRpcClient;199}
200
201export function bindWsRpcClientEntryEnvironment(
202 clientKey: string,
203 environmentId: EnvironmentId,
204): void {
205 const entry = wsRpcClientEntriesByKey.get(clientKey);
206 if (!entry) {
207 throw new Error(`No websocket client registered for key ${clientKey}.`);
208 }
209
210 const previousBoundEnvironmentId = entry.environmentId;
211 const previousKeyForEnvironment = wsRpcClientKeyByEnvironmentId.get(environmentId);
212
213 if (previousBoundEnvironmentId === environmentId && previousKeyForEnvironment === clientKey) {
214 return;
215 }
216
217 if (previousBoundEnvironmentId) {
218 wsRpcClientKeyByEnvironmentId.delete(previousBoundEnvironmentId);
219 }
220
221 if (previousKeyForEnvironment && previousKeyForEnvironment !== clientKey) {
222 const previousEntry = wsRpcClientEntriesByKey.get(previousKeyForEnvironment);
223 if (previousEntry) {
224 previousEntry.environmentId = null;
225 }
226 }
227
228 entry.environmentId = environmentId;
229 wsRpcClientKeyByEnvironmentId.set(environmentId, clientKey);
230 emitWsRpcClientRegistryChange();
231}
232
233export function bindPrimaryWsRpcClientEnvironment(environmentId: EnvironmentId): void {
234 bindWsRpcClientEntryEnvironment(getPrimaryWsRpcClientEntry().key, environmentId);
235}
236
237export function readWsRpcClientEntryForEnvironment(
238 environmentId: EnvironmentId,
239): WsRpcClientEntry | null {
240 const clientKey = wsRpcClientKeyByEnvironmentId.get(environmentId);
241 if (clientKey) {
242 const entry = wsRpcClientEntriesByKey.get(clientKey);
243 return entry ? toReadonlyEntry(entry) : null;
244 }
245
246 return null;
247}
248
249export function getWsRpcClientForEnvironment(environmentId: EnvironmentId): WsRpcClient {
250 const entry = readWsRpcClientEntryForEnvironment(environmentId);
251 if (!entry) {
252 throw new Error(`No websocket client registered for environment ${environmentId}.`);
117 }253 }
118 sharedWsRpcClient = createWsRpcClient();254 return entry.client;
119 return sharedWsRpcClient;
120}255}
121256
122export async function __resetWsRpcClientForTests() {257export async function __resetWsRpcClientForTests() {
123 await sharedWsRpcClient?.dispose();258 for (const entry of wsRpcClientEntriesByKey.values()) {
124 sharedWsRpcClient = null;259 await entry.client.dispose();
260 }
261 wsRpcClientEntriesByKey.clear();
262 wsRpcClientKeyByEnvironmentId.clear();
263 wsRpcClientRegistryListeners.clear();
125}264}
126265
127export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {266export function createWsRpcClient(
267 transport = new WsTransport(resolvePrimaryEnvironmentBootstrapUrl()),
268): WsRpcClient {
128 return {269 return {
129 dispose: () => transport.dispose(),270 dispose: () => transport.dispose(),
130 reconnect: async () => {271 reconnect: async () => {
apps/web/src/wsTransport.test.ts+21
...@@ -436,6 +436,13 @@ describe("WsTransport", () => {...@@ -436,6 +436,13 @@ describe("WsTransport", () => {
436 sequence: 1,436 sequence: 1,
437 type: "welcome",437 type: "welcome",
438 payload: {438 payload: {
439 environment: {
440 environmentId: "environment-local",
441 label: "Local environment",
442 platform: { os: "darwin", arch: "arm64" },
443 serverVersion: "0.0.0-test",
444 capabilities: { repositoryIdentity: true },
445 },
439 cwd: "/tmp/workspace",446 cwd: "/tmp/workspace",
440 projectName: "workspace",447 projectName: "workspace",
441 },448 },
...@@ -489,6 +496,13 @@ describe("WsTransport", () => {...@@ -489,6 +496,13 @@ describe("WsTransport", () => {
489 sequence: 1,496 sequence: 1,
490 type: "welcome",497 type: "welcome",
491 payload: {498 payload: {
499 environment: {
500 environmentId: "environment-local",
501 label: "Local environment",
502 platform: { os: "darwin", arch: "arm64" },
503 serverVersion: "0.0.0-test",
504 capabilities: { repositoryIdentity: true },
505 },
492 cwd: "/tmp/one",506 cwd: "/tmp/one",
493 projectName: "one",507 projectName: "one",
494 },508 },
...@@ -532,6 +546,13 @@ describe("WsTransport", () => {...@@ -532,6 +546,13 @@ describe("WsTransport", () => {
532 sequence: 2,546 sequence: 2,
533 type: "welcome",547 type: "welcome",
534 payload: {548 payload: {
549 environment: {
550 environmentId: "environment-local",
551 label: "Local environment",
552 platform: { os: "darwin", arch: "arm64" },
553 serverVersion: "0.0.0-test",
554 capabilities: { repositoryIdentity: true },
555 },
535 cwd: "/tmp/two",556 cwd: "/tmp/two",
536 projectName: "two",557 projectName: "two",
537 },558 },
bun.lock+15
...@@ -82,6 +82,7 @@...@@ -82,6 +82,7 @@
82 "@formkit/auto-animate": "^0.9.0",82 "@formkit/auto-animate": "^0.9.0",
83 "@lexical/react": "^0.41.0",83 "@lexical/react": "^0.41.0",
84 "@pierre/diffs": "^1.1.0-beta.16",84 "@pierre/diffs": "^1.1.0-beta.16",
85 "@t3tools/client-runtime": "workspace:*",
85 "@t3tools/contracts": "workspace:*",86 "@t3tools/contracts": "workspace:*",
86 "@t3tools/shared": "workspace:*",87 "@t3tools/shared": "workspace:*",
87 "@tanstack/react-pacer": "^0.19.4",88 "@tanstack/react-pacer": "^0.19.4",
...@@ -121,6 +122,18 @@...@@ -121,6 +122,18 @@
121 "vitest-browser-react": "^2.0.5",122 "vitest-browser-react": "^2.0.5",
122 },123 },
123 },124 },
125 "packages/client-runtime": {
126 "name": "@t3tools/client-runtime",
127 "version": "0.0.0-alpha.1",
128 "dependencies": {
129 "@t3tools/contracts": "workspace:*",
130 },
131 "devDependencies": {
132 "@effect/language-service": "catalog:",
133 "typescript": "catalog:",
134 "vitest": "catalog:",
135 },
136 },
124 "packages/contracts": {137 "packages/contracts": {
125 "name": "@t3tools/contracts",138 "name": "@t3tools/contracts",
126 "version": "0.0.15",139 "version": "0.0.15",
...@@ -659,6 +672,8 @@...@@ -659,6 +672,8 @@
659672
660 "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],673 "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
661674
675 "@t3tools/client-runtime": ["@t3tools/client-runtime@workspace:packages/client-runtime"],
676
662 "@t3tools/contracts": ["@t3tools/contracts@workspace:packages/contracts"],677 "@t3tools/contracts": ["@t3tools/contracts@workspace:packages/contracts"],
663678
664 "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"],679 "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"],
packages/client-runtime/package.json created+25
...@@ -0,0 +1,25 @@
1{
2 "name": "@t3tools/client-runtime",
3 "version": "0.0.0-alpha.1",
4 "private": true,
5 "type": "module",
6 "exports": {
7 ".": {
8 "types": "./src/index.ts",
9 "import": "./src/index.ts"
10 }
11 },
12 "scripts": {
13 "prepare": "effect-language-service patch",
14 "typecheck": "tsc --noEmit",
15 "test": "vitest run"
16 },
17 "dependencies": {
18 "@t3tools/contracts": "workspace:*"
19 },
20 "devDependencies": {
21 "@effect/language-service": "catalog:",
22 "typescript": "catalog:",
23 "vitest": "catalog:"
24 }
25}
packages/client-runtime/src/index.ts created+2
...@@ -0,0 +1,2 @@
1export * from "./knownEnvironment";
2export * from "./scoped";
packages/client-runtime/src/knownEnvironment.test.ts created+63
...@@ -0,0 +1,63 @@
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { createKnownEnvironmentFromWsUrl } from "./knownEnvironment";
5import {
6 parseScopedProjectKey,
7 parseScopedThreadKey,
8 scopedProjectKey,
9 scopedRefKey,
10 scopedThreadKey,
11 scopeProjectRef,
12 scopeThreadRef,
13} from "./scoped";
14
15describe("known environment bootstrap helpers", () => {
16 it("creates known environments from explicit ws urls", () => {
17 expect(
18 createKnownEnvironmentFromWsUrl({
19 label: "Remote environment",
20 wsUrl: "wss://remote.example.com/ws",
21 }),
22 ).toEqual({
23 id: "ws:Remote environment",
24 label: "Remote environment",
25 source: "manual",
26 target: {
27 type: "ws",
28 wsUrl: "wss://remote.example.com/ws",
29 },
30 });
31 });
32});
33
34describe("scoped refs", () => {
35 const environmentId = EnvironmentId.makeUnsafe("environment-test");
36 const projectRef = scopeProjectRef(environmentId, ProjectId.makeUnsafe("project-1"));
37 const threadRef = scopeThreadRef(environmentId, ThreadId.makeUnsafe("thread-1"));
38
39 it("builds stable scoped project and thread keys", () => {
40 expect(scopedRefKey(projectRef)).toBe("environment-test:project-1");
41 expect(scopedRefKey(threadRef)).toBe("environment-test:thread-1");
42 expect(scopedProjectKey(projectRef)).toBe("environment-test:project-1");
43 expect(scopedThreadKey(threadRef)).toBe("environment-test:thread-1");
44 });
45
46 it("returns typed scoped refs", () => {
47 expect(projectRef).toEqual({
48 environmentId,
49 projectId: ProjectId.makeUnsafe("project-1"),
50 });
51 expect(threadRef).toEqual({
52 environmentId,
53 threadId: ThreadId.makeUnsafe("thread-1"),
54 });
55 });
56
57 it("parses scoped project and thread keys back into refs", () => {
58 expect(parseScopedProjectKey("environment-test:project-1")).toEqual(projectRef);
59 expect(parseScopedThreadKey("environment-test:thread-1")).toEqual(threadRef);
60 expect(parseScopedProjectKey("bad-key")).toBeNull();
61 expect(parseScopedThreadKey("bad-key")).toBeNull();
62 });
63});
packages/client-runtime/src/knownEnvironment.ts created+39
...@@ -0,0 +1,39 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3export interface KnownEnvironmentConnectionTarget {
4 readonly type: "ws";
5 readonly wsUrl: string;
6}
7
8export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin";
9
10export interface KnownEnvironment {
11 readonly id: string;
12 readonly label: string;
13 readonly source: KnownEnvironmentSource;
14 readonly environmentId?: EnvironmentId;
15 readonly target: KnownEnvironmentConnectionTarget;
16}
17
18export function createKnownEnvironmentFromWsUrl(input: {
19 readonly id?: string;
20 readonly label: string;
21 readonly source?: KnownEnvironmentSource;
22 readonly wsUrl: string;
23}): KnownEnvironment {
24 return {
25 id: input.id ?? `ws:${input.label}`,
26 label: input.label,
27 source: input.source ?? "manual",
28 target: {
29 type: "ws",
30 wsUrl: input.wsUrl,
31 },
32 };
33}
34
35export function getKnownEnvironmentBaseUrl(
36 environment: KnownEnvironment | null | undefined,
37): string | null {
38 return environment?.target.wsUrl ?? null;
39}
packages/client-runtime/src/scoped.ts created+64
...@@ -0,0 +1,64 @@
1import type {
2 EnvironmentId,
3 ProjectId,
4 ScopedProjectRef,
5 ScopedThreadRef,
6 ThreadId,
7} from "@t3tools/contracts";
8
9export function scopeProjectRef(
10 environmentId: EnvironmentId,
11 projectId: ProjectId,
12): ScopedProjectRef {
13 return { environmentId, projectId };
14}
15
16export function scopeThreadRef(environmentId: EnvironmentId, threadId: ThreadId): ScopedThreadRef {
17 return { environmentId, threadId };
18}
19
20export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string {
21 const localId = "projectId" in ref ? ref.projectId : ref.threadId;
22 return `${ref.environmentId}:${localId}`;
23}
24
25export function scopedProjectKey(ref: ScopedProjectRef): string {
26 return scopedRefKey(ref);
27}
28
29export function scopedThreadKey(ref: ScopedThreadRef): string {
30 return scopedRefKey(ref);
31}
32
33function parseScopedKey(key: string): { environmentId: EnvironmentId; localId: string } | null {
34 const separatorIndex = key.indexOf(":");
35 if (separatorIndex <= 0 || separatorIndex >= key.length - 1) {
36 return null;
37 }
38 return {
39 environmentId: key.slice(0, separatorIndex) as EnvironmentId,
40 localId: key.slice(separatorIndex + 1),
41 };
42}
43
44export function parseScopedProjectKey(key: string): ScopedProjectRef | null {
45 const parsed = parseScopedKey(key);
46 if (!parsed) {
47 return null;
48 }
49 return {
50 environmentId: parsed.environmentId,
51 projectId: parsed.localId as ProjectId,
52 };
53}
54
55export function parseScopedThreadKey(key: string): ScopedThreadRef | null {
56 const parsed = parseScopedKey(key);
57 if (!parsed) {
58 return null;
59 }
60 return {
61 environmentId: parsed.environmentId,
62 threadId: parsed.localId as ThreadId,
63 };
64}
packages/client-runtime/tsconfig.json created+4
...@@ -0,0 +1,4 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "include": ["src"]
4}
packages/contracts/src/baseSchemas.ts+2
...@@ -19,6 +19,8 @@ export const ThreadId = makeEntityId("ThreadId");...@@ -19,6 +19,8 @@ export const ThreadId = makeEntityId("ThreadId");
19export type ThreadId = typeof ThreadId.Type;19export type ThreadId = typeof ThreadId.Type;
20export const ProjectId = makeEntityId("ProjectId");20export const ProjectId = makeEntityId("ProjectId");
21export type ProjectId = typeof ProjectId.Type;21export type ProjectId = typeof ProjectId.Type;
22export const EnvironmentId = makeEntityId("EnvironmentId");
23export type EnvironmentId = typeof EnvironmentId.Type;
22export const CommandId = makeEntityId("CommandId");24export const CommandId = makeEntityId("CommandId");
23export type CommandId = typeof CommandId.Type;25export type CommandId = typeof CommandId.Type;
24export const EventId = makeEntityId("EventId");26export const EventId = makeEntityId("EventId");
packages/contracts/src/environment.ts created+77
...@@ -0,0 +1,77 @@
1import { Schema } from "effect";
2
3import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas";
4
5export const ExecutionEnvironmentPlatformOs = Schema.Literals([
6 "darwin",
7 "linux",
8 "windows",
9 "unknown",
10]);
11export type ExecutionEnvironmentPlatformOs = typeof ExecutionEnvironmentPlatformOs.Type;
12
13export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]);
14export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type;
15
16export const ExecutionEnvironmentPlatform = Schema.Struct({
17 os: ExecutionEnvironmentPlatformOs,
18 arch: ExecutionEnvironmentPlatformArch,
19});
20export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type;
21
22export const ExecutionEnvironmentCapabilities = Schema.Struct({
23 repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),
24});
25export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
26
27export const ExecutionEnvironmentDescriptor = Schema.Struct({
28 environmentId: EnvironmentId,
29 label: TrimmedNonEmptyString,
30 platform: ExecutionEnvironmentPlatform,
31 serverVersion: TrimmedNonEmptyString,
32 capabilities: ExecutionEnvironmentCapabilities,
33});
34export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type;
35
36export const EnvironmentConnectionState = Schema.Literals([
37 "connecting",
38 "connected",
39 "disconnected",
40 "error",
41]);
42export type EnvironmentConnectionState = typeof EnvironmentConnectionState.Type;
43
44export const RepositoryIdentityLocator = Schema.Struct({
45 source: Schema.Literal("git-remote"),
46 remoteName: TrimmedNonEmptyString,
47 remoteUrl: TrimmedNonEmptyString,
48});
49export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type;
50
51export const RepositoryIdentity = Schema.Struct({
52 canonicalKey: TrimmedNonEmptyString,
53 locator: RepositoryIdentityLocator,
54 displayName: Schema.optionalKey(TrimmedNonEmptyString),
55 provider: Schema.optionalKey(TrimmedNonEmptyString),
56 owner: Schema.optionalKey(TrimmedNonEmptyString),
57 name: Schema.optionalKey(TrimmedNonEmptyString),
58});
59export type RepositoryIdentity = typeof RepositoryIdentity.Type;
60
61export const ScopedProjectRef = Schema.Struct({
62 environmentId: EnvironmentId,
63 projectId: ProjectId,
64});
65export type ScopedProjectRef = typeof ScopedProjectRef.Type;
66
67export const ScopedThreadRef = Schema.Struct({
68 environmentId: EnvironmentId,
69 threadId: ThreadId,
70});
71export type ScopedThreadRef = typeof ScopedThreadRef.Type;
72
73export const ScopedThreadSessionRef = Schema.Struct({
74 environmentId: EnvironmentId,
75 threadId: ThreadId,
76});
77export type ScopedThreadSessionRef = typeof ScopedThreadSessionRef.Type;
packages/contracts/src/index.ts+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1export * from "./baseSchemas";1export * from "./baseSchemas";
2export * from "./environment";
2export * from "./ipc";3export * from "./ipc";
3export * from "./terminal";4export * from "./terminal";
4export * from "./provider";5export * from "./provider";
packages/contracts/src/ipc.ts+46-20
...@@ -105,8 +105,14 @@ export interface DesktopUpdateCheckResult {...@@ -105,8 +105,14 @@ export interface DesktopUpdateCheckResult {
105 state: DesktopUpdateState;105 state: DesktopUpdateState;
106}106}
107107
108export interface DesktopEnvironmentBootstrap {
109 label: string;
110 wsUrl: string | null;
111}
112
108export interface DesktopBridge {113export interface DesktopBridge {
109 getWsUrl: () => string | null;114 getWsUrl: () => string | null;
115 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
110 pickFolder: () => Promise<string | null>;116 pickFolder: () => Promise<string | null>;
111 confirm: (message: string) => Promise<boolean>;117 confirm: (message: string) => Promise<boolean>;
112 setTheme: (theme: DesktopTheme) => Promise<void>;118 setTheme: (theme: DesktopTheme) => Promise<void>;
...@@ -123,11 +129,50 @@ export interface DesktopBridge {...@@ -123,11 +129,50 @@ export interface DesktopBridge {
123 onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;129 onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;
124}130}
125131
126export interface NativeApi {132/**
133 * APIs bound to the local app shell, not to any particular backend environment.
134 *
135 * These capabilities describe the desktop/browser host that the user is
136 * currently running: dialogs, editor/external-link opening, context menus, and
137 * app-level settings/config access. They must not be used as a proxy for
138 * "whatever environment the user is targeting", because in a multi-environment
139 * world the local shell and a selected backend environment are distinct
140 * concepts.
141 */
142export interface LocalApi {
127 dialogs: {143 dialogs: {
128 pickFolder: () => Promise<string | null>;144 pickFolder: () => Promise<string | null>;
129 confirm: (message: string) => Promise<boolean>;145 confirm: (message: string) => Promise<boolean>;
130 };146 };
147 shell: {
148 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
149 openExternal: (url: string) => Promise<void>;
150 };
151 contextMenu: {
152 show: <T extends string>(
153 items: readonly ContextMenuItem<T>[],
154 position?: { x: number; y: number },
155 ) => Promise<T | null>;
156 };
157 server: {
158 getConfig: () => Promise<ServerConfig>;
159 refreshProviders: () => Promise<ServerProviderUpdatedPayload>;
160 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
161 getSettings: () => Promise<ServerSettings>;
162 updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;
163 };
164}
165
166/**
167 * APIs bound to a specific backend environment connection.
168 *
169 * These operations must always be routed with explicit environment context.
170 * They represent remote stateful capabilities such as orchestration, terminal,
171 * project, and git operations. In multi-environment mode, each environment gets
172 * its own instance of this surface, and callers should resolve it by
173 * `environmentId` rather than reaching through the local desktop bridge.
174 */
175export interface EnvironmentApi {
131 terminal: {176 terminal: {
132 open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>;177 open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>;
133 write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>;178 write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>;
...@@ -141,12 +186,7 @@ export interface NativeApi {...@@ -141,12 +186,7 @@ export interface NativeApi {
141 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;186 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
142 writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;187 writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;
143 };188 };
144 shell: {
145 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
146 openExternal: (url: string) => Promise<void>;
147 };
148 git: {189 git: {
149 // Existing branch/worktree API
150 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;190 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;
151 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;191 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;
152 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;192 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;
...@@ -157,7 +197,6 @@ export interface NativeApi {...@@ -157,7 +197,6 @@ export interface NativeApi {
157 preparePullRequestThread: (197 preparePullRequestThread: (
158 input: GitPreparePullRequestThreadInput,198 input: GitPreparePullRequestThreadInput,
159 ) => Promise<GitPreparePullRequestThreadResult>;199 ) => Promise<GitPreparePullRequestThreadResult>;
160 // Stacked action API
161 pull: (input: GitPullInput) => Promise<GitPullResult>;200 pull: (input: GitPullInput) => Promise<GitPullResult>;
162 refreshStatus: (input: GitStatusInput) => Promise<GitStatusResult>;201 refreshStatus: (input: GitStatusInput) => Promise<GitStatusResult>;
163 onStatus: (202 onStatus: (
...@@ -168,19 +207,6 @@ export interface NativeApi {...@@ -168,19 +207,6 @@ export interface NativeApi {
168 },207 },
169 ) => () => void;208 ) => () => void;
170 };209 };
171 contextMenu: {
172 show: <T extends string>(
173 items: readonly ContextMenuItem<T>[],
174 position?: { x: number; y: number },
175 ) => Promise<T | null>;
176 };
177 server: {
178 getConfig: () => Promise<ServerConfig>;
179 refreshProviders: () => Promise<ServerProviderUpdatedPayload>;
180 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
181 getSettings: () => Promise<ServerSettings>;
182 updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;
183 };
184 orchestration: {210 orchestration: {
185 getSnapshot: () => Promise<OrchestrationReadModel>;211 getSnapshot: () => Promise<OrchestrationReadModel>;
186 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;212 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
packages/contracts/src/orchestration.ts+4
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { Option, Schema, SchemaIssue, Struct } from "effect";1import { Option, Schema, SchemaIssue, Struct } from "effect";
2import { ClaudeModelOptions, CodexModelOptions } from "./model";2import { ClaudeModelOptions, CodexModelOptions } from "./model";
3import { RepositoryIdentity } from "./environment";
3import {4import {
4 ApprovalRequestId,5 ApprovalRequestId,
5 CheckpointRef,6 CheckpointRef,
...@@ -141,6 +142,7 @@ export const OrchestrationProject = Schema.Struct({...@@ -141,6 +142,7 @@ export const OrchestrationProject = Schema.Struct({
141 id: ProjectId,142 id: ProjectId,
142 title: TrimmedNonEmptyString,143 title: TrimmedNonEmptyString,
143 workspaceRoot: TrimmedNonEmptyString,144 workspaceRoot: TrimmedNonEmptyString,
145 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
144 defaultModelSelection: Schema.NullOr(ModelSelection),146 defaultModelSelection: Schema.NullOr(ModelSelection),
145 scripts: Schema.Array(ProjectScript),147 scripts: Schema.Array(ProjectScript),
146 createdAt: IsoDateTime,148 createdAt: IsoDateTime,
...@@ -647,6 +649,7 @@ export const ProjectCreatedPayload = Schema.Struct({...@@ -647,6 +649,7 @@ export const ProjectCreatedPayload = Schema.Struct({
647 projectId: ProjectId,649 projectId: ProjectId,
648 title: TrimmedNonEmptyString,650 title: TrimmedNonEmptyString,
649 workspaceRoot: TrimmedNonEmptyString,651 workspaceRoot: TrimmedNonEmptyString,
652 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
650 defaultModelSelection: Schema.NullOr(ModelSelection),653 defaultModelSelection: Schema.NullOr(ModelSelection),
651 scripts: Schema.Array(ProjectScript),654 scripts: Schema.Array(ProjectScript),
652 createdAt: IsoDateTime,655 createdAt: IsoDateTime,
...@@ -657,6 +660,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({...@@ -657,6 +660,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
657 projectId: ProjectId,660 projectId: ProjectId,
658 title: Schema.optional(TrimmedNonEmptyString),661 title: Schema.optional(TrimmedNonEmptyString),
659 workspaceRoot: Schema.optional(TrimmedNonEmptyString),662 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
663 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
660 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),664 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
661 scripts: Schema.optional(Schema.Array(ProjectScript)),665 scripts: Schema.optional(Schema.Array(ProjectScript)),
662 updatedAt: IsoDateTime,666 updatedAt: IsoDateTime,
packages/contracts/src/server.ts+4
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { ExecutionEnvironmentDescriptor } from "./environment";
2import {3import {
3 IsoDateTime,4 IsoDateTime,
4 NonNegativeInt,5 NonNegativeInt,
...@@ -83,6 +84,7 @@ export const ServerObservability = Schema.Struct({...@@ -83,6 +84,7 @@ export const ServerObservability = Schema.Struct({
83export type ServerObservability = typeof ServerObservability.Type;84export type ServerObservability = typeof ServerObservability.Type;
8485
85export const ServerConfig = Schema.Struct({86export const ServerConfig = Schema.Struct({
87 environment: ExecutionEnvironmentDescriptor,
86 cwd: TrimmedNonEmptyString,88 cwd: TrimmedNonEmptyString,
87 keybindingsConfigPath: TrimmedNonEmptyString,89 keybindingsConfigPath: TrimmedNonEmptyString,
88 keybindings: ResolvedKeybindingsConfig,90 keybindings: ResolvedKeybindingsConfig,
...@@ -167,10 +169,12 @@ export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;...@@ -167,10 +169,12 @@ export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;
167169
168export const ServerLifecycleReadyPayload = Schema.Struct({170export const ServerLifecycleReadyPayload = Schema.Struct({
169 at: IsoDateTime,171 at: IsoDateTime,
172 environment: ExecutionEnvironmentDescriptor,
170});173});
171export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type;174export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type;
172175
173export const ServerLifecycleWelcomePayload = Schema.Struct({176export const ServerLifecycleWelcomePayload = Schema.Struct({
177 environment: ExecutionEnvironmentDescriptor,
174 cwd: TrimmedNonEmptyString,178 cwd: TrimmedNonEmptyString,
175 projectName: TrimmedNonEmptyString,179 projectName: TrimmedNonEmptyString,
176 bootstrapProjectId: Schema.optional(ProjectId),180 bootstrapProjectId: Schema.optional(ProjectId),
packages/shared/src/git.test.ts+48-1
...@@ -1,7 +1,54 @@...@@ -1,7 +1,54 @@
1import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts";1import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { applyGitStatusStreamEvent } from "./git";4import {
5 applyGitStatusStreamEvent,
6 normalizeGitRemoteUrl,
7 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
8} from "./git";
9
10describe("normalizeGitRemoteUrl", () => {
11 it("canonicalizes equivalent GitHub remotes across protocol variants", () => {
12 expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe(
13 "github.com/t3tools/t3code",
14 );
15 expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe(
16 "github.com/t3tools/t3code",
17 );
18 expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe(
19 "github.com/t3tools/t3code",
20 );
21 });
22
23 it("preserves nested group paths for providers like GitLab", () => {
24 expect(normalizeGitRemoteUrl("git@gitlab.com:T3Tools/platform/T3Code.git")).toBe(
25 "gitlab.com/t3tools/platform/t3code",
26 );
27 expect(normalizeGitRemoteUrl("https://gitlab.com/T3Tools/platform/T3Code.git")).toBe(
28 "gitlab.com/t3tools/platform/t3code",
29 );
30 });
31
32 it("drops explicit ports from URL-shaped remotes", () => {
33 expect(normalizeGitRemoteUrl("https://gitlab.company.com:8443/team/project.git")).toBe(
34 "gitlab.company.com/team/project",
35 );
36 expect(normalizeGitRemoteUrl("ssh://git@gitlab.company.com:2222/team/project.git")).toBe(
37 "gitlab.company.com/team/project",
38 );
39 });
40});
41
42describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {
43 it("extracts the owner and repository from common GitHub remote shapes", () => {
44 expect(
45 parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"),
46 ).toBe("T3Tools/T3Code");
47 expect(
48 parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"),
49 ).toBe("T3Tools/T3Code");
50 });
51});
552
6describe("applyGitStatusStreamEvent", () => {53describe("applyGitStatusStreamEvent", () => {
7 it("treats a remote-only update as a repository when local state is missing", () => {54 it("treats a remote-only update as a repository when local state is missing", () => {
packages/shared/src/git.ts+50
...@@ -80,6 +80,56 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {...@@ -80,6 +80,56 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
80 return branchName.slice(firstSeparatorIndex + 1);80 return branchName.slice(firstSeparatorIndex + 1);
81}81}
8282
83/**
84 * Normalize a git remote URL into a stable comparison key.
85 */
86export function normalizeGitRemoteUrl(value: string): string {
87 const normalized = value
88 .trim()
89 .replace(/\/+$/g, "")
90 .replace(/\.git$/i, "")
91 .toLowerCase();
92
93 if (/^(?:ssh|https?|git):\/\//i.test(normalized)) {
94 try {
95 const url = new URL(normalized);
96 const repositoryPath = url.pathname
97 .split("/")
98 .filter((segment) => segment.length > 0)
99 .join("/");
100 if (url.hostname && repositoryPath.includes("/")) {
101 return `${url.hostname}/${repositoryPath}`;
102 }
103 } catch {
104 return normalized;
105 }
106 }
107
108 const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized);
109 if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) {
110 return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`;
111 }
112
113 return normalized;
114}
115
116/**
117 * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes.
118 */
119export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
120 const trimmed = url?.trim() ?? "";
121 if (trimmed.length === 0) {
122 return null;
123 }
124
125 const match =
126 /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
127 trimmed,
128 );
129 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
130 return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;
131}
132
83function deriveLocalBranchNameCandidatesFromRemoteRef(133function deriveLocalBranchNameCandidatesFromRemoteRef(
84 branchName: string,134 branchName: string,
85 remoteName?: string,135 remoteName?: string,
scripts/release-smoke.ts+1
...@@ -13,6 +13,7 @@ const workspaceFiles = [...@@ -13,6 +13,7 @@ const workspaceFiles = [
13 "apps/desktop/package.json",13 "apps/desktop/package.json",
14 "apps/web/package.json",14 "apps/web/package.json",
15 "apps/marketing/package.json",15 "apps/marketing/package.json",
16 "packages/client-runtime/package.json",
16 "packages/contracts/package.json",17 "packages/contracts/package.json",
17 "packages/shared/package.json",18 "packages/shared/package.json",
18 "scripts/package.json",19 "scripts/package.json",