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";
6060const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
6161const UPDATE_CHECK_CHANNEL = "desktop:update-check";
6262const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
63const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
6364const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
6465const STATE_DIR = Path.join(BASE_DIR, "userdata");
6566const DESKTOP_SCHEME = "t3";
......@@ -1172,6 +1173,14 @@ function registerIpcHandlers(): void {
11721173 event.returnValue = backendWsUrl;
11731174 });
11741175
1176 ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
1177 ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
1178 event.returnValue = {
1179 label: "Local environment",
1180 wsUrl: backendWsUrl || null,
1181 } as const;
1182 });
1183
11751184 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
11761185 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
11771186 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
apps/desktop/src/preload.ts+8
......@@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check";
1313const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1414const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
1515const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
16const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
1617
1718contextBridge.exposeInMainWorld("desktopBridge", {
1819 getWsUrl: () => {
1920 const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
2021 return typeof result === "string" ? result : null;
2122 },
23 getLocalEnvironmentBootstrap: () => {
24 const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
25 if (typeof result !== "object" || result === null) {
26 return null;
27 }
28 return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>;
29 },
2230 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
2331 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
2432 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
apps/server/integration/OrchestrationEngineHarness.integration.ts+2
......@@ -45,6 +45,7 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";
4545import { ProviderService } from "../src/provider/Services/ProviderService.ts";
4646import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
4747import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
48import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts";
4849import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
4950import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
5051import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
......@@ -338,6 +339,7 @@ export const makeOrchestrationIntegrationHarness = (
338339 Layer.provideMerge(runtimeServicesLayer),
339340 Layer.provideMerge(orchestrationReactorLayer),
340341 Layer.provide(persistenceLayer),
342 Layer.provideMerge(RepositoryIdentityResolverLive),
341343 Layer.provideMerge(ServerSettingsService.layerTest()),
342344 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
343345 Layer.provideMerge(NodeServices.layer),
apps/server/src/config.ts+2
......@@ -30,6 +30,7 @@ export interface ServerDerivedPaths {
3030 readonly providerEventLogPath: string;
3131 readonly terminalLogsDir: string;
3232 readonly anonymousIdPath: string;
33 readonly environmentIdPath: string;
3334}
3435
3536/**
......@@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* (
8384 providerEventLogPath: join(providerLogsDir, "events.log"),
8485 terminalLogsDir: join(logsDir, "terminals"),
8586 anonymousIdPath: join(stateDir, "anonymous-id"),
87 environmentIdPath: join(stateDir, "environment-id"),
8688 };
8789});
8890
apps/server/src/environment/Layers/ServerEnvironment.test.ts 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) => {
854854 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
855855 );
856856 }),
857 12_000,
857 20_000,
858858 );
859859
860860 it.effect(
......@@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
962962 ),
963963 ).toBe(false);
964964 }),
965 12_000,
965 20_000,
966966 );
967967
968968 it.effect("status returns merged PR state when latest PR was merged", () =>
......@@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
16851685 false,
16861686 );
16871687 }),
1688 12_000,
1688 20_000,
16891689 );
16901690
16911691 it.effect(
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2
......@@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2020import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
2121import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
2222import { GitCoreLive } from "../../git/Layers/GitCore.ts";
23import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2324import { CheckpointReactorLive } from "./CheckpointReactor.ts";
2425import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2526import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
......@@ -256,6 +257,7 @@ describe("CheckpointReactor", () => {
256257 Layer.provide(OrchestrationProjectionPipelineLive),
257258 Layer.provide(OrchestrationEventStoreLive),
258259 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
260 Layer.provide(RepositoryIdentityResolverLive),
259261 Layer.provide(SqlitePersistenceMemory),
260262 );
261263
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5
......@@ -19,6 +19,7 @@ import {
1919 OrchestrationEventStore,
2020 type OrchestrationEventStoreShape,
2121} from "../../persistence/Services/OrchestrationEventStore.ts";
22import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2223import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2324import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
2425import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
......@@ -45,6 +46,7 @@ async function createOrchestrationSystem() {
4546 Layer.provide(OrchestrationProjectionPipelineLive),
4647 Layer.provide(OrchestrationEventStoreLive),
4748 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
49 Layer.provide(RepositoryIdentityResolverLive),
4850 Layer.provide(SqlitePersistenceMemory),
4951 Layer.provideMerge(ServerConfigLayer),
5052 Layer.provideMerge(NodeServices.layer),
......@@ -623,6 +625,7 @@ describe("OrchestrationEngine", () => {
623625 Layer.provide(OrchestrationProjectionPipelineLive),
624626 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
625627 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
628 Layer.provide(RepositoryIdentityResolverLive),
626629 Layer.provide(SqlitePersistenceMemory),
627630 Layer.provideMerge(ServerConfigLayer),
628631 Layer.provideMerge(NodeServices.layer),
......@@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => {
719722 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
720723 Layer.provide(OrchestrationEventStoreLive),
721724 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
725 Layer.provide(RepositoryIdentityResolverLive),
722726 Layer.provide(SqlitePersistenceMemory),
723727 ),
724728 );
......@@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => {
861865 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
862866 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
863867 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
868 Layer.provide(RepositoryIdentityResolverLive),
864869 Layer.provide(SqlitePersistenceMemory),
865870 ),
866871 );
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2
......@@ -20,6 +20,7 @@ import {
2020 SqlitePersistenceMemory,
2121} from "../../persistence/Layers/Sqlite.ts";
2222import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
23import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2324import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2425import {
2526 ORCHESTRATION_PROJECTOR_NAMES,
......@@ -1846,6 +1847,7 @@ const engineLayer = it.layer(
18461847 Layer.provide(OrchestrationProjectionPipelineLive),
18471848 Layer.provide(OrchestrationEventStoreLive),
18481849 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
1850 Layer.provide(RepositoryIdentityResolverLive),
18491851 Layer.provideMerge(SqlitePersistenceMemory),
18501852 Layer.provideMerge(
18511853 ServerConfig.layerTest(process.cwd(), {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+6-1
......@@ -4,6 +4,7 @@ import { Effect, Layer } from "effect";
44import * as SqlClient from "effect/unstable/sql/SqlClient";
55
66import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
7import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
78import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
89import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
910import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
......@@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
1516const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
1617
1718const projectionSnapshotLayer = it.layer(
18 OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
19 OrchestrationProjectionSnapshotQueryLive.pipe(
20 Layer.provideMerge(RepositoryIdentityResolverLive),
21 Layer.provideMerge(SqlitePersistenceMemory),
22 ),
1923);
2024
2125projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
......@@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
234238 id: asProjectId("project-1"),
235239 title: "Project 1",
236240 workspaceRoot: "/tmp/project-1",
241 repositoryIdentity: null,
237242 defaultModelSelection: {
238243 provider: "codex",
239244 model: "gpt-5-codex",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+278-256
......@@ -38,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh
3838import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
3939import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
4040import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
41import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
4142import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
4243import {
4344 ProjectionSnapshotQuery,
......@@ -163,6 +164,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
163164
164165const makeProjectionSnapshotQuery = Effect.gen(function* () {
165166 const sql = yield* SqlClient.SqlClient;
167 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
168 const repositoryIdentityResolutionConcurrency = 4;
166169
167170 const listProjectRows = SqlSchema.findAll({
168171 Request: Schema.Void,
......@@ -436,269 +439,283 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
436439 const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () =>
437440 sql
438441 .withTransaction(
439 Effect.gen(function* () {
440 const [
441 projectRows,
442 threadRows,
443 messageRows,
444 proposedPlanRows,
445 activityRows,
446 sessionRows,
447 checkpointRows,
448 latestTurnRows,
449 stateRows,
450 ] = yield* Effect.all([
451 listProjectRows(undefined).pipe(
452 Effect.mapError(
453 toPersistenceSqlOrDecodeError(
454 "ProjectionSnapshotQuery.getSnapshot:listProjects:query",
455 "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows",
456 ),
457 ),
458 ),
459 listThreadRows(undefined).pipe(
460 Effect.mapError(
461 toPersistenceSqlOrDecodeError(
462 "ProjectionSnapshotQuery.getSnapshot:listThreads:query",
463 "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows",
464 ),
442 Effect.all([
443 listProjectRows(undefined).pipe(
444 Effect.mapError(
445 toPersistenceSqlOrDecodeError(
446 "ProjectionSnapshotQuery.getSnapshot:listProjects:query",
447 "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows",
465448 ),
466449 ),
467 listThreadMessageRows(undefined).pipe(
468 Effect.mapError(
469 toPersistenceSqlOrDecodeError(
470 "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query",
471 "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows",
472 ),
450 ),
451 listThreadRows(undefined).pipe(
452 Effect.mapError(
453 toPersistenceSqlOrDecodeError(
454 "ProjectionSnapshotQuery.getSnapshot:listThreads:query",
455 "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows",
473456 ),
474457 ),
475 listThreadProposedPlanRows(undefined).pipe(
476 Effect.mapError(
477 toPersistenceSqlOrDecodeError(
478 "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query",
479 "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows",
480 ),
458 ),
459 listThreadMessageRows(undefined).pipe(
460 Effect.mapError(
461 toPersistenceSqlOrDecodeError(
462 "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query",
463 "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows",
481464 ),
482465 ),
483 listThreadActivityRows(undefined).pipe(
484 Effect.mapError(
485 toPersistenceSqlOrDecodeError(
486 "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query",
487 "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows",
488 ),
466 ),
467 listThreadProposedPlanRows(undefined).pipe(
468 Effect.mapError(
469 toPersistenceSqlOrDecodeError(
470 "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query",
471 "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows",
489472 ),
490473 ),
491 listThreadSessionRows(undefined).pipe(
492 Effect.mapError(
493 toPersistenceSqlOrDecodeError(
494 "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query",
495 "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows",
496 ),
474 ),
475 listThreadActivityRows(undefined).pipe(
476 Effect.mapError(
477 toPersistenceSqlOrDecodeError(
478 "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query",
479 "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows",
497480 ),
498481 ),
499 listCheckpointRows(undefined).pipe(
500 Effect.mapError(
501 toPersistenceSqlOrDecodeError(
502 "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query",
503 "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows",
504 ),
482 ),
483 listThreadSessionRows(undefined).pipe(
484 Effect.mapError(
485 toPersistenceSqlOrDecodeError(
486 "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query",
487 "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows",
505488 ),
506489 ),
507 listLatestTurnRows(undefined).pipe(
508 Effect.mapError(
509 toPersistenceSqlOrDecodeError(
510 "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query",
511 "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows",
512 ),
490 ),
491 listCheckpointRows(undefined).pipe(
492 Effect.mapError(
493 toPersistenceSqlOrDecodeError(
494 "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query",
495 "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows",
513496 ),
514497 ),
515 listProjectionStateRows(undefined).pipe(
516 Effect.mapError(
517 toPersistenceSqlOrDecodeError(
518 "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query",
519 "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows",
520 ),
498 ),
499 listLatestTurnRows(undefined).pipe(
500 Effect.mapError(
501 toPersistenceSqlOrDecodeError(
502 "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query",
503 "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows",
521504 ),
522505 ),
523 ]);
524
525 const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
526 const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
527 const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
528 const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
529 const sessionsByThread = new Map<string, OrchestrationSession>();
530 const latestTurnByThread = new Map<string, OrchestrationLatestTurn>();
531
532 let updatedAt: string | null = null;
533
534 for (const row of projectRows) {
535 updatedAt = maxIso(updatedAt, row.updatedAt);
536 }
537 for (const row of threadRows) {
538 updatedAt = maxIso(updatedAt, row.updatedAt);
539 }
540 for (const row of stateRows) {
541 updatedAt = maxIso(updatedAt, row.updatedAt);
542 }
543
544 for (const row of messageRows) {
545 updatedAt = maxIso(updatedAt, row.updatedAt);
546 const threadMessages = messagesByThread.get(row.threadId) ?? [];
547 threadMessages.push({
548 id: row.messageId,
549 role: row.role,
550 text: row.text,
551 ...(row.attachments !== null ? { attachments: row.attachments } : {}),
552 turnId: row.turnId,
553 streaming: row.isStreaming === 1,
554 createdAt: row.createdAt,
555 updatedAt: row.updatedAt,
556 });
557 messagesByThread.set(row.threadId, threadMessages);
558 }
559
560 for (const row of proposedPlanRows) {
561 updatedAt = maxIso(updatedAt, row.updatedAt);
562 const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? [];
563 threadProposedPlans.push({
564 id: row.planId,
565 turnId: row.turnId,
566 planMarkdown: row.planMarkdown,
567 implementedAt: row.implementedAt,
568 implementationThreadId: row.implementationThreadId,
569 createdAt: row.createdAt,
570 updatedAt: row.updatedAt,
571 });
572 proposedPlansByThread.set(row.threadId, threadProposedPlans);
573 }
574
575 for (const row of activityRows) {
576 updatedAt = maxIso(updatedAt, row.createdAt);
577 const threadActivities = activitiesByThread.get(row.threadId) ?? [];
578 threadActivities.push({
579 id: row.activityId,
580 tone: row.tone,
581 kind: row.kind,
582 summary: row.summary,
583 payload: row.payload,
584 turnId: row.turnId,
585 ...(row.sequence !== null ? { sequence: row.sequence } : {}),
586 createdAt: row.createdAt,
587 });
588 activitiesByThread.set(row.threadId, threadActivities);
589 }
590
591 for (const row of checkpointRows) {
592 updatedAt = maxIso(updatedAt, row.completedAt);
593 const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? [];
594 threadCheckpoints.push({
595 turnId: row.turnId,
596 checkpointTurnCount: row.checkpointTurnCount,
597 checkpointRef: row.checkpointRef,
598 status: row.status,
599 files: row.files,
600 assistantMessageId: row.assistantMessageId,
601 completedAt: row.completedAt,
602 });
603 checkpointsByThread.set(row.threadId, threadCheckpoints);
604 }
605
606 for (const row of latestTurnRows) {
607 updatedAt = maxIso(updatedAt, row.requestedAt);
608 if (row.startedAt !== null) {
609 updatedAt = maxIso(updatedAt, row.startedAt);
610 }
611 if (row.completedAt !== null) {
612 updatedAt = maxIso(updatedAt, row.completedAt);
613 }
614 if (latestTurnByThread.has(row.threadId)) {
615 continue;
616 }
617 latestTurnByThread.set(row.threadId, {
618 turnId: row.turnId,
619 state:
620 row.state === "error"
621 ? "error"
622 : row.state === "interrupted"
623 ? "interrupted"
624 : row.state === "completed"
625 ? "completed"
626 : "running",
627 requestedAt: row.requestedAt,
628 startedAt: row.startedAt,
629 completedAt: row.completedAt,
630 assistantMessageId: row.assistantMessageId,
631 ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null
632 ? {
633 sourceProposedPlan: {
634 threadId: row.sourceProposedPlanThreadId,
635 planId: row.sourceProposedPlanId,
636 },
637 }
638 : {}),
639 });
640 }
641
642 for (const row of sessionRows) {
643 updatedAt = maxIso(updatedAt, row.updatedAt);
644 sessionsByThread.set(row.threadId, {
645 threadId: row.threadId,
646 status: row.status,
647 providerName: row.providerName,
648 runtimeMode: row.runtimeMode,
649 activeTurnId: row.activeTurnId,
650 lastError: row.lastError,
651 updatedAt: row.updatedAt,
652 });
653 }
654
655 const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
656 id: row.projectId,
657 title: row.title,
658 workspaceRoot: row.workspaceRoot,
659 defaultModelSelection: row.defaultModelSelection,
660 scripts: row.scripts,
661 createdAt: row.createdAt,
662 updatedAt: row.updatedAt,
663 deletedAt: row.deletedAt,
664 }));
665
666 const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
667 id: row.threadId,
668 projectId: row.projectId,
669 title: row.title,
670 modelSelection: row.modelSelection,
671 runtimeMode: row.runtimeMode,
672 interactionMode: row.interactionMode,
673 branch: row.branch,
674 worktreePath: row.worktreePath,
675 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
676 createdAt: row.createdAt,
677 updatedAt: row.updatedAt,
678 archivedAt: row.archivedAt,
679 deletedAt: row.deletedAt,
680 messages: messagesByThread.get(row.threadId) ?? [],
681 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
682 activities: activitiesByThread.get(row.threadId) ?? [],
683 checkpoints: checkpointsByThread.get(row.threadId) ?? [],
684 session: sessionsByThread.get(row.threadId) ?? null,
685 }));
686
687 const snapshot = {
688 snapshotSequence: computeSnapshotSequence(stateRows),
689 projects,
690 threads,
691 updatedAt: updatedAt ?? new Date(0).toISOString(),
692 };
693
694 return yield* decodeReadModel(snapshot).pipe(
506 ),
507 listProjectionStateRows(undefined).pipe(
695508 Effect.mapError(
696 toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"),
509 toPersistenceSqlOrDecodeError(
510 "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query",
511 "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows",
512 ),
697513 ),
698 );
699 }),
514 ),
515 ]),
700516 )
701517 .pipe(
518 Effect.flatMap(
519 ([
520 projectRows,
521 threadRows,
522 messageRows,
523 proposedPlanRows,
524 activityRows,
525 sessionRows,
526 checkpointRows,
527 latestTurnRows,
528 stateRows,
529 ]) =>
530 Effect.gen(function* () {
531 const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
532 const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
533 const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
534 const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
535 const sessionsByThread = new Map<string, OrchestrationSession>();
536 const latestTurnByThread = new Map<string, OrchestrationLatestTurn>();
537
538 let updatedAt: string | null = null;
539
540 for (const row of projectRows) {
541 updatedAt = maxIso(updatedAt, row.updatedAt);
542 }
543 for (const row of threadRows) {
544 updatedAt = maxIso(updatedAt, row.updatedAt);
545 }
546 for (const row of stateRows) {
547 updatedAt = maxIso(updatedAt, row.updatedAt);
548 }
549
550 for (const row of messageRows) {
551 updatedAt = maxIso(updatedAt, row.updatedAt);
552 const threadMessages = messagesByThread.get(row.threadId) ?? [];
553 threadMessages.push({
554 id: row.messageId,
555 role: row.role,
556 text: row.text,
557 ...(row.attachments !== null ? { attachments: row.attachments } : {}),
558 turnId: row.turnId,
559 streaming: row.isStreaming === 1,
560 createdAt: row.createdAt,
561 updatedAt: row.updatedAt,
562 });
563 messagesByThread.set(row.threadId, threadMessages);
564 }
565
566 for (const row of proposedPlanRows) {
567 updatedAt = maxIso(updatedAt, row.updatedAt);
568 const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? [];
569 threadProposedPlans.push({
570 id: row.planId,
571 turnId: row.turnId,
572 planMarkdown: row.planMarkdown,
573 implementedAt: row.implementedAt,
574 implementationThreadId: row.implementationThreadId,
575 createdAt: row.createdAt,
576 updatedAt: row.updatedAt,
577 });
578 proposedPlansByThread.set(row.threadId, threadProposedPlans);
579 }
580
581 for (const row of activityRows) {
582 updatedAt = maxIso(updatedAt, row.createdAt);
583 const threadActivities = activitiesByThread.get(row.threadId) ?? [];
584 threadActivities.push({
585 id: row.activityId,
586 tone: row.tone,
587 kind: row.kind,
588 summary: row.summary,
589 payload: row.payload,
590 turnId: row.turnId,
591 ...(row.sequence !== null ? { sequence: row.sequence } : {}),
592 createdAt: row.createdAt,
593 });
594 activitiesByThread.set(row.threadId, threadActivities);
595 }
596
597 for (const row of checkpointRows) {
598 updatedAt = maxIso(updatedAt, row.completedAt);
599 const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? [];
600 threadCheckpoints.push({
601 turnId: row.turnId,
602 checkpointTurnCount: row.checkpointTurnCount,
603 checkpointRef: row.checkpointRef,
604 status: row.status,
605 files: row.files,
606 assistantMessageId: row.assistantMessageId,
607 completedAt: row.completedAt,
608 });
609 checkpointsByThread.set(row.threadId, threadCheckpoints);
610 }
611
612 for (const row of latestTurnRows) {
613 updatedAt = maxIso(updatedAt, row.requestedAt);
614 if (row.startedAt !== null) {
615 updatedAt = maxIso(updatedAt, row.startedAt);
616 }
617 if (row.completedAt !== null) {
618 updatedAt = maxIso(updatedAt, row.completedAt);
619 }
620 if (latestTurnByThread.has(row.threadId)) {
621 continue;
622 }
623 latestTurnByThread.set(row.threadId, {
624 turnId: row.turnId,
625 state:
626 row.state === "error"
627 ? "error"
628 : row.state === "interrupted"
629 ? "interrupted"
630 : row.state === "completed"
631 ? "completed"
632 : "running",
633 requestedAt: row.requestedAt,
634 startedAt: row.startedAt,
635 completedAt: row.completedAt,
636 assistantMessageId: row.assistantMessageId,
637 ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null
638 ? {
639 sourceProposedPlan: {
640 threadId: row.sourceProposedPlanThreadId,
641 planId: row.sourceProposedPlanId,
642 },
643 }
644 : {}),
645 });
646 }
647
648 for (const row of sessionRows) {
649 updatedAt = maxIso(updatedAt, row.updatedAt);
650 sessionsByThread.set(row.threadId, {
651 threadId: row.threadId,
652 status: row.status,
653 providerName: row.providerName,
654 runtimeMode: row.runtimeMode,
655 activeTurnId: row.activeTurnId,
656 lastError: row.lastError,
657 updatedAt: row.updatedAt,
658 });
659 }
660
661 const repositoryIdentities = new Map(
662 yield* Effect.forEach(
663 projectRows,
664 (row) =>
665 repositoryIdentityResolver
666 .resolve(row.workspaceRoot)
667 .pipe(Effect.map((identity) => [row.projectId, identity] as const)),
668 { concurrency: repositoryIdentityResolutionConcurrency },
669 ),
670 );
671
672 const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
673 id: row.projectId,
674 title: row.title,
675 workspaceRoot: row.workspaceRoot,
676 repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
677 defaultModelSelection: row.defaultModelSelection,
678 scripts: row.scripts,
679 createdAt: row.createdAt,
680 updatedAt: row.updatedAt,
681 deletedAt: row.deletedAt,
682 }));
683
684 const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
685 id: row.threadId,
686 projectId: row.projectId,
687 title: row.title,
688 modelSelection: row.modelSelection,
689 runtimeMode: row.runtimeMode,
690 interactionMode: row.interactionMode,
691 branch: row.branch,
692 worktreePath: row.worktreePath,
693 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
694 createdAt: row.createdAt,
695 updatedAt: row.updatedAt,
696 archivedAt: row.archivedAt,
697 deletedAt: row.deletedAt,
698 messages: messagesByThread.get(row.threadId) ?? [],
699 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
700 activities: activitiesByThread.get(row.threadId) ?? [],
701 checkpoints: checkpointsByThread.get(row.threadId) ?? [],
702 session: sessionsByThread.get(row.threadId) ?? null,
703 }));
704
705 const snapshot = {
706 snapshotSequence: computeSnapshotSequence(stateRows),
707 projects,
708 threads,
709 updatedAt: updatedAt ?? new Date(0).toISOString(),
710 };
711
712 return yield* decodeReadModel(snapshot).pipe(
713 Effect.mapError(
714 toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"),
715 ),
716 );
717 }),
718 ),
702719 Effect.mapError((error) => {
703720 if (isPersistenceError(error)) {
704721 return error;
......@@ -732,19 +749,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
732749 "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow",
733750 ),
734751 ),
735 Effect.map(
736 Option.map(
737 (row): OrchestrationProject => ({
738 id: row.projectId,
739 title: row.title,
740 workspaceRoot: row.workspaceRoot,
741 defaultModelSelection: row.defaultModelSelection,
742 scripts: row.scripts,
743 createdAt: row.createdAt,
744 updatedAt: row.updatedAt,
745 deletedAt: row.deletedAt,
746 }),
747 ),
752 Effect.flatMap((option) =>
753 Option.isNone(option)
754 ? Effect.succeed(Option.none<OrchestrationProject>())
755 : repositoryIdentityResolver.resolve(option.value.workspaceRoot).pipe(
756 Effect.map((repositoryIdentity) =>
757 Option.some({
758 id: option.value.projectId,
759 title: option.value.title,
760 workspaceRoot: option.value.workspaceRoot,
761 repositoryIdentity,
762 defaultModelSelection: option.value.defaultModelSelection,
763 scripts: option.value.scripts,
764 createdAt: option.value.createdAt,
765 updatedAt: option.value.updatedAt,
766 deletedAt: option.value.deletedAt,
767 } satisfies OrchestrationProject),
768 ),
769 ),
748770 ),
749771 );
750772
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+2
......@@ -28,6 +28,7 @@ import {
2828} from "../../provider/Services/ProviderService.ts";
2929import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";
3030import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";
31import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
3132import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
3233import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3334import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
......@@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => {
218219 Layer.provide(OrchestrationProjectionPipelineLive),
219220 Layer.provide(OrchestrationEventStoreLive),
220221 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
222 Layer.provide(RepositoryIdentityResolverLive),
221223 Layer.provide(SqlitePersistenceMemory),
222224 );
223225 const layer = ProviderCommandReactorLive.pipe(
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+2
......@@ -29,6 +29,7 @@ import {
2929 ProviderService,
3030 type ProviderServiceShape,
3131} from "../../provider/Services/ProviderService.ts";
32import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
3233import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
3334import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3435import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
......@@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => {
204205 Layer.provide(OrchestrationProjectionPipelineLive),
205206 Layer.provide(OrchestrationEventStoreLive),
206207 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
208 Layer.provide(RepositoryIdentityResolverLive),
207209 Layer.provide(SqlitePersistenceMemory),
208210 );
209211 const layer = ProviderRuntimeIngestionLive.pipe(
apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts 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";
55import {
66 CommandId,
77 DEFAULT_SERVER_SETTINGS,
8 EnvironmentId,
9 EventId,
810 GitCommandError,
911 KeybindingRule,
1012 MessageId,
......@@ -83,6 +85,14 @@ import {
8385 ProjectSetupScriptRunner,
8486 type ProjectSetupScriptRunnerShape,
8587} 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";
8696import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
8797import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
8898import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
......@@ -93,6 +103,18 @@ const defaultModelSelection = {
93103 provider: "codex",
94104 model: "gpt-5-codex",
95105} 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
97119const makeDefaultOrchestrationReadModel = () => {
98120 const now = new Date().toISOString();
......@@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: {
270292 browserTraceCollector?: Partial<BrowserTraceCollectorShape>;
271293 serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>;
272294 serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>;
295 serverEnvironment?: Partial<ServerEnvironmentShape>;
296 repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>;
273297 };
274298}) =>
275299 Effect.gen(function* () {
......@@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: {
416440 ...options?.layers?.serverRuntimeStartup,
417441 }),
418442 ),
443 Layer.provide(
444 Layer.mock(ServerEnvironment)({
445 getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId),
446 getDescriptor: Effect.succeed(testEnvironmentDescriptor),
447 ...options?.layers?.serverEnvironment,
448 }),
449 ),
450 Layer.provide(
451 Layer.mock(RepositoryIdentityResolver)({
452 resolve: () => Effect.succeed(null),
453 ...options?.layers?.repositoryIdentityResolver,
454 }),
455 ),
419456 Layer.provide(workspaceAndProjectServicesLayer),
420457 Layer.provideMerge(FetchHttpClient.layer),
421458 Layer.provide(layerConfig),
......@@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10691106 sequence: 1,
10701107 type: "welcome" as const,
10711108 payload: {
1109 environment: testEnvironmentDescriptor,
10721110 cwd: "/tmp/project",
10731111 projectName: "project",
10741112 },
......@@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10781116 version: 1 as const,
10791117 sequence: 2,
10801118 type: "ready" as const,
1081 payload: { at: new Date().toISOString() },
1119 payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor },
10821120 });
10831121
10841122 yield* buildAppUnderTest({
......@@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
19962034 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
19972035 );
19982036
2037 it.effect("enriches replayed project events with repository identity metadata", () =>
2038 Effect.gen(function* () {
2039 const repositoryIdentity = {
2040 canonicalKey: "github.com/t3tools/t3code",
2041 locator: {
2042 source: "git-remote" as const,
2043 remoteName: "origin",
2044 remoteUrl: "git@github.com:T3Tools/t3code.git",
2045 },
2046 displayName: "T3Tools/t3code",
2047 provider: "github",
2048 owner: "T3Tools",
2049 name: "t3code",
2050 };
2051
2052 yield* buildAppUnderTest({
2053 layers: {
2054 orchestrationEngine: {
2055 readEvents: (_fromSequenceExclusive) =>
2056 Stream.make({
2057 sequence: 1,
2058 eventId: EventId.makeUnsafe("event-1"),
2059 aggregateKind: "project",
2060 aggregateId: defaultProjectId,
2061 occurredAt: "2026-04-05T00:00:00.000Z",
2062 commandId: null,
2063 causationEventId: null,
2064 correlationId: null,
2065 metadata: {},
2066 type: "project.created",
2067 payload: {
2068 projectId: defaultProjectId,
2069 title: "Default Project",
2070 workspaceRoot: "/tmp/default-project",
2071 defaultModelSelection,
2072 scripts: [],
2073 createdAt: "2026-04-05T00:00:00.000Z",
2074 updatedAt: "2026-04-05T00:00:00.000Z",
2075 },
2076 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>),
2077 },
2078 repositoryIdentityResolver: {
2079 resolve: () => Effect.succeed(repositoryIdentity),
2080 },
2081 },
2082 });
2083
2084 const wsUrl = yield* getWsServerUrl("/ws");
2085 const replayResult = yield* Effect.scoped(
2086 withWsRpcClient(wsUrl, (client) =>
2087 client[ORCHESTRATION_WS_METHODS.replayEvents]({
2088 fromSequenceExclusive: 0,
2089 }),
2090 ),
2091 );
2092
2093 const replayedEvent = replayResult[0];
2094 assert.equal(replayedEvent?.type, "project.created");
2095 assert.deepEqual(
2096 replayedEvent && replayedEvent.type === "project.created"
2097 ? replayedEvent.payload.repositoryIdentity
2098 : null,
2099 repositoryIdentity,
2100 );
2101 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2102 );
2103
19992104 it.effect("closes thread terminals after a successful archive command", () =>
20002105 Effect.gen(function* () {
20012106 const threadId = ThreadId.makeUnsafe("thread-archive");
......@@ -2498,6 +2603,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
24982603 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
24992604 );
25002605
2606 it.effect("enriches replayed project events only once before streaming them to subscribers", () =>
2607 Effect.gen(function* () {
2608 let resolveCalls = 0;
2609 const repositoryIdentity = {
2610 canonicalKey: "github.com/t3tools/t3code",
2611 locator: {
2612 source: "git-remote" as const,
2613 remoteName: "origin",
2614 remoteUrl: "git@github.com:t3tools/t3code.git",
2615 },
2616 displayName: "t3tools/t3code",
2617 provider: "github" as const,
2618 owner: "t3tools",
2619 name: "t3code",
2620 };
2621
2622 yield* buildAppUnderTest({
2623 layers: {
2624 orchestrationEngine: {
2625 getReadModel: () =>
2626 Effect.succeed({
2627 ...makeDefaultOrchestrationReadModel(),
2628 snapshotSequence: 0,
2629 }),
2630 readEvents: () =>
2631 Stream.make({
2632 sequence: 1,
2633 eventId: EventId.makeUnsafe("event-1"),
2634 aggregateKind: "project",
2635 aggregateId: defaultProjectId,
2636 occurredAt: "2026-04-06T00:00:00.000Z",
2637 commandId: null,
2638 causationEventId: null,
2639 correlationId: null,
2640 metadata: {},
2641 type: "project.meta-updated",
2642 payload: {
2643 projectId: defaultProjectId,
2644 title: "Replayed Project",
2645 updatedAt: "2026-04-06T00:00:00.000Z",
2646 },
2647 } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>),
2648 streamDomainEvents: Stream.empty,
2649 },
2650 repositoryIdentityResolver: {
2651 resolve: () => {
2652 resolveCalls += 1;
2653 return Effect.succeed(repositoryIdentity);
2654 },
2655 },
2656 },
2657 });
2658
2659 const wsUrl = yield* getWsServerUrl("/ws");
2660 const events = yield* Effect.scoped(
2661 withWsRpcClient(wsUrl, (client) =>
2662 client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
2663 Stream.take(1),
2664 Stream.runCollect,
2665 ),
2666 ),
2667 );
2668
2669 const event = Array.from(events)[0];
2670 assert.equal(resolveCalls, 1);
2671 assert.equal(event?.type, "project.meta-updated");
2672 assert.deepEqual(
2673 event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null,
2674 repositoryIdentity,
2675 );
2676 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2677 );
2678
2679 it.effect("enriches subscribed project meta updates with repository identity metadata", () =>
2680 Effect.gen(function* () {
2681 const repositoryIdentity = {
2682 canonicalKey: "github.com/t3tools/t3code",
2683 locator: {
2684 source: "git-remote" as const,
2685 remoteName: "upstream",
2686 remoteUrl: "git@github.com:T3Tools/t3code.git",
2687 },
2688 displayName: "T3Tools/t3code",
2689 provider: "github",
2690 owner: "T3Tools",
2691 name: "t3code",
2692 };
2693
2694 yield* buildAppUnderTest({
2695 layers: {
2696 orchestrationEngine: {
2697 getReadModel: () =>
2698 Effect.succeed({
2699 ...makeDefaultOrchestrationReadModel(),
2700 snapshotSequence: 0,
2701 }),
2702 streamDomainEvents: Stream.make({
2703 sequence: 1,
2704 eventId: EventId.makeUnsafe("event-1"),
2705 aggregateKind: "project",
2706 aggregateId: defaultProjectId,
2707 occurredAt: "2026-04-05T00:00:00.000Z",
2708 commandId: null,
2709 causationEventId: null,
2710 correlationId: null,
2711 metadata: {},
2712 type: "project.meta-updated",
2713 payload: {
2714 projectId: defaultProjectId,
2715 title: "Renamed Project",
2716 updatedAt: "2026-04-05T00:00:00.000Z",
2717 },
2718 } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>),
2719 },
2720 repositoryIdentityResolver: {
2721 resolve: () => Effect.succeed(repositoryIdentity),
2722 },
2723 },
2724 });
2725
2726 const wsUrl = yield* getWsServerUrl("/ws");
2727 const events = yield* Effect.scoped(
2728 withWsRpcClient(wsUrl, (client) =>
2729 client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
2730 Stream.take(1),
2731 Stream.runCollect,
2732 ),
2733 ),
2734 );
2735
2736 const event = Array.from(events)[0];
2737 assert.equal(event?.type, "project.meta-updated");
2738 assert.deepEqual(
2739 event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null,
2740 repositoryIdentity,
2741 );
2742 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2743 );
2744
25012745 it.effect("routes websocket rpc orchestration.getSnapshot errors", () =>
25022746 Effect.gen(function* () {
25032747 yield* buildAppUnderTest({
apps/server/src/server.ts+4
......@@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"
4444import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
4545import { ServerSettingsLive } from "./serverSettings";
4646import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver";
47import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver";
4748import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries";
4849import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem";
4950import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";
5051import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner";
5152import { ObservabilityLive } from "./observability/Layers/Observability";
53import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment";
5254
5355const PtyAdapterLive = Layer.unwrap(
5456 Effect.gen(function* () {
......@@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe(
199201 Layer.provideMerge(ServerSettingsLive),
200202 Layer.provideMerge(WorkspaceLayerLive),
201203 Layer.provideMerge(ProjectFaviconResolverLive),
204 Layer.provideMerge(RepositoryIdentityResolverLive),
205 Layer.provideMerge(ServerEnvironmentLive),
202206
203207 // Misc.
204208 Layer.provideMerge(AnalyticsServiceLayerLive),
apps/server/src/serverLifecycleEvents.test.ts+10
......@@ -1,3 +1,4 @@
1import { EnvironmentId } from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
23import { assertTrue } from "@effect/vitest/utils";
34import { Effect, Option } from "effect";
......@@ -9,12 +10,20 @@ it.effect(
910 () =>
1011 Effect.gen(function* () {
1112 const lifecycleEvents = yield* ServerLifecycleEvents;
13 const environment = {
14 environmentId: EnvironmentId.makeUnsafe("environment-test"),
15 label: "Test environment",
16 platform: { os: "darwin" as const, arch: "arm64" as const },
17 serverVersion: "0.0.0-test",
18 capabilities: { repositoryIdentity: true },
19 };
1220
1321 const welcome = yield* lifecycleEvents
1422 .publish({
1523 version: 1,
1624 type: "welcome",
1725 payload: {
26 environment,
1827 cwd: "/tmp/project",
1928 projectName: "project",
2029 },
......@@ -29,6 +38,7 @@ it.effect(
2938 type: "ready",
3039 payload: {
3140 at: new Date().toISOString(),
41 environment,
3242 },
3343 })
3444 .pipe(Effect.timeoutOption("50 millis"));
apps/server/src/serverRuntimeStartup.ts+12-2
......@@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap
2727import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
2828import { ServerLifecycleEvents } from "./serverLifecycleEvents";
2929import { ServerSettingsService } from "./serverSettings";
30import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
3031import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3132
3233const isWildcardHost = (host: string | undefined): boolean =>
......@@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
262263 const orchestrationReactor = yield* OrchestrationReactor;
263264 const lifecycleEvents = yield* ServerLifecycleEvents;
264265 const serverSettings = yield* ServerSettingsService;
266 const serverEnvironment = yield* ServerEnvironment;
265267
266268 const commandGate = yield* makeCommandGate;
267269 const httpListening = yield* Deferred.make<void>();
......@@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
308310
309311 yield* Effect.logDebug("startup phase: preparing welcome payload");
310312 const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome);
313 const environment = yield* serverEnvironment.getDescriptor;
311314 yield* Effect.logDebug("startup phase: publishing welcome event", {
315 environmentId: environment.environmentId,
312316 cwd: welcome.cwd,
313317 projectName: welcome.projectName,
314318 bootstrapProjectId: welcome.bootstrapProjectId,
......@@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
319323 lifecycleEvents.publish({
320324 version: 1,
321325 type: "welcome",
322 payload: welcome,
326 payload: {
327 environment,
328 ...welcome,
329 },
323330 }),
324331 );
325332 }).pipe(
......@@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
354361 lifecycleEvents.publish({
355362 version: 1,
356363 type: "ready",
357 payload: { at: new Date().toISOString() },
364 payload: {
365 at: new Date().toISOString(),
366 environment: yield* serverEnvironment.getDescriptor,
367 },
358368 }),
359369 );
360370
apps/server/src/ws.ts+55-1
......@@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries";
4747import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem";
4848import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths";
4949import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner";
50import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver";
51import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
5052
5153const WsRpcLayer = WsRpcGroup.toLayer(
5254 Effect.gen(function* () {
......@@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer(
6769 const workspaceEntries = yield* WorkspaceEntries;
6870 const workspaceFileSystem = yield* WorkspaceFileSystem;
6971 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
72 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
73 const serverEnvironment = yield* ServerEnvironment;
7074 const serverCommandId = (tag: string) =>
7175 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
7276
......@@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer(
113117 });
114118 };
115119
120 const enrichProjectEvent = (
121 event: OrchestrationEvent,
122 ): Effect.Effect<OrchestrationEvent, never, never> => {
123 switch (event.type) {
124 case "project.created":
125 return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe(
126 Effect.map((repositoryIdentity) => ({
127 ...event,
128 payload: {
129 ...event.payload,
130 repositoryIdentity,
131 },
132 })),
133 );
134 case "project.meta-updated":
135 return Effect.gen(function* () {
136 const workspaceRoot =
137 event.payload.workspaceRoot ??
138 (yield* orchestrationEngine.getReadModel()).projects.find(
139 (project) => project.id === event.payload.projectId,
140 )?.workspaceRoot ??
141 null;
142 if (workspaceRoot === null) {
143 return event;
144 }
145
146 const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot);
147 return {
148 ...event,
149 payload: {
150 ...event.payload,
151 repositoryIdentity,
152 },
153 } satisfies OrchestrationEvent;
154 });
155 default:
156 return Effect.succeed(event);
157 }
158 };
159
160 const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) =>
161 Effect.forEach(events, enrichProjectEvent, { concurrency: 4 });
162
116163 const dispatchBootstrapTurnStart = (
117164 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,
118165 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>
......@@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer(
329376 const keybindingsConfig = yield* keybindings.loadConfigState;
330377 const providers = yield* providerRegistry.getProviders;
331378 const settings = yield* serverSettings.getSettings;
379 const environment = yield* serverEnvironment.getDescriptor;
332380
333381 return {
382 environment,
334383 cwd: config.cwd,
335384 keybindingsConfigPath: config.keybindingsConfigPath,
336385 keybindings: keybindingsConfig.keybindings,
......@@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer(
435484 ),
436485 ).pipe(
437486 Effect.map((events) => Array.from(events)),
487 Effect.flatMap(enrichOrchestrationEvents),
438488 Effect.mapError(
439489 (cause) =>
440490 new OrchestrationReplayEventsError({
......@@ -455,10 +505,14 @@ const WsRpcLayer = WsRpcGroup.toLayer(
455505 orchestrationEngine.readEvents(fromSequenceExclusive),
456506 ).pipe(
457507 Effect.map((events) => Array.from(events)),
508 Effect.flatMap(enrichOrchestrationEvents),
458509 Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),
459510 );
460511 const replayStream = Stream.fromIterable(replayEvents);
461 const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents);
512 const liveStream = orchestrationEngine.streamDomainEvents.pipe(
513 Stream.mapEffect(enrichProjectEvent),
514 );
515 const source = Stream.merge(replayStream, liveStream);
462516 type SequenceState = {
463517 readonly nextSequence: number;
464518 readonly pendingBySequence: Map<number, OrchestrationEvent>;
apps/web/index.html+1
......@@ -12,6 +12,7 @@
1212 rel="stylesheet"
1313 />
1414 <title>T3 Code (Alpha)</title>
15 <script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>
1516 </head>
1617 <body>
1718 <div id="root"></div>
apps/web/package.json+1
......@@ -23,6 +23,7 @@
2323 "@formkit/auto-animate": "^0.9.0",
2424 "@lexical/react": "^0.41.0",
2525 "@pierre/diffs": "^1.1.0-beta.16",
26 "@t3tools/client-runtime": "workspace:*",
2627 "@t3tools/contracts": "workspace:*",
2728 "@t3tools/shared": "workspace:*",
2829 "@tanstack/react-pacer": "^0.19.4",
apps/web/src/components/BranchToolbar.tsx+36-14
......@@ -1,11 +1,13 @@
1import type { ThreadId } from "@t3tools/contracts";
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
23import { 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";
58import { newCommandId } from "../lib/utils";
6import { readNativeApi } from "../nativeApi";
7import { useComposerDraftStore } from "../composerDraftStore";
89import { useStore } from "../store";
10import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
911import {
1012 EnvMode,
1113 resolveDraftEnvModeAfterBranchChange,
......@@ -20,7 +22,9 @@ const envModeItems = [
2022] as const;
2123
2224interface BranchToolbarProps {
25 environmentId: EnvironmentId;
2326 threadId: ThreadId;
27 draftId?: DraftId;
2428 onEnvModeChange: (mode: EnvMode) => void;
2529 envLocked: boolean;
2630 onCheckoutPullRequestRequest?: (reference: string) => void;
......@@ -28,21 +32,34 @@ interface BranchToolbarProps {
2832}
2933
3034export default function BranchToolbar({
35 environmentId,
3136 threadId,
37 draftId,
3238 onEnvModeChange,
3339 envLocked,
3440 onCheckoutPullRequestRequest,
3541 onComposerFocusRequest,
3642}: BranchToolbarProps) {
37 const serverThread = useStore((store) => store.threadShellById[threadId]);
38 const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null);
43 const threadRef = useMemo(
44 () => scopeThreadRef(environmentId, threadId),
45 [environmentId, threadId],
46 );
47 const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]);
48 const serverThread = useStore(serverThreadSelector);
49 const serverSession = serverThread?.session ?? null;
3950 const setThreadBranchAction = useStore((store) => store.setThreadBranch);
40 const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));
51 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
4152 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
42 const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null;
43 const activeProject = useStore((store) =>
44 activeProjectId ? store.projectById[activeProjectId] : undefined,
53 const activeProjectRef = serverThread
54 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
55 : draftThread
56 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
57 : null;
58 const activeProjectSelector = useMemo(
59 () => createProjectSelectorByRef(activeProjectRef),
60 [activeProjectRef],
4561 );
62 const activeProject = useStore(activeProjectSelector);
4663 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
4764 const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
4865 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
......@@ -56,8 +73,8 @@ export default function BranchToolbar({
5673
5774 const setThreadBranch = useCallback(
5875 (branch: string | null, worktreePath: string | null) => {
59 if (!activeThreadId) return;
60 const api = readNativeApi();
76 if (!activeThreadId || !activeProject) return;
77 const api = readEnvironmentApi(environmentId);
6178 // If the effective cwd is about to change, stop the running session so the
6279 // next message creates a new one with the correct cwd.
6380 if (serverSession && worktreePath !== activeWorktreePath && api) {
......@@ -88,20 +105,24 @@ export default function BranchToolbar({
88105 currentWorktreePath: activeWorktreePath,
89106 effectiveEnvMode,
90107 });
91 setDraftThreadContext(threadId, {
108 setDraftThreadContext(draftId ?? threadRef, {
92109 branch,
93110 worktreePath,
94111 envMode: nextDraftEnvMode,
112 projectRef: scopeProjectRef(environmentId, activeProject.id),
95113 });
96114 },
97115 [
98116 activeThreadId,
117 activeProject,
99118 serverSession,
100119 activeWorktreePath,
101120 hasServerThread,
102121 setThreadBranchAction,
103122 setDraftThreadContext,
104 threadId,
123 draftId,
124 threadRef,
125 environmentId,
105126 effectiveEnvMode,
106127 ],
107128 );
......@@ -156,6 +177,7 @@ export default function BranchToolbar({
156177 )}
157178
158179 <BranchToolbarBranchSelector
180 environmentId={environmentId}
159181 activeProjectCwd={activeProject.cwd}
160182 activeThreadBranch={activeThreadBranch}
161183 activeWorktreePath={activeWorktreePath}
apps/web/src/components/BranchToolbarBranchSelector.tsx+13-10
......@@ -1,4 +1,4 @@
1import type { GitBranch } from "@t3tools/contracts";
1import type { EnvironmentId, GitBranch } from "@t3tools/contracts";
22import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
33import { useVirtualizer } from "@tanstack/react-virtual";
44import { ChevronDownIcon } from "lucide-react";
......@@ -16,7 +16,7 @@ import {
1616
1717import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery";
1818import { useGitStatus } from "../lib/gitStatusState";
19import { readNativeApi } from "../nativeApi";
19import { readEnvironmentApi } from "../environmentApi";
2020import { parsePullRequestReference } from "../pullRequestReference";
2121import {
2222 deriveLocalBranchNameFromRemoteRef,
......@@ -39,6 +39,7 @@ import {
3939import { toastManager } from "./ui/toast";
4040
4141interface BranchToolbarBranchSelectorProps {
42 environmentId: EnvironmentId;
4243 activeProjectCwd: string;
4344 activeThreadBranch: string | null;
4445 activeWorktreePath: string | null;
......@@ -70,6 +71,7 @@ function getBranchTriggerLabel(input: {
7071}
7172
7273export function BranchToolbarBranchSelector({
74 environmentId,
7375 activeProjectCwd,
7476 activeThreadBranch,
7577 activeWorktreePath,
......@@ -85,16 +87,16 @@ export function BranchToolbarBranchSelector({
8587 const [branchQuery, setBranchQuery] = useState("");
8688 const deferredBranchQuery = useDeferredValue(branchQuery);
8789
88 const branchStatusQuery = useGitStatus(branchCwd);
90 const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });
8991 const trimmedBranchQuery = branchQuery.trim();
9092 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
9193
9294 useEffect(() => {
9395 if (!branchCwd) return;
9496 void queryClient.prefetchInfiniteQuery(
95 gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }),
97 gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }),
9698 );
97 }, [branchCwd, queryClient]);
99 }, [branchCwd, environmentId, queryClient]);
98100
99101 const {
100102 data: branchesSearchData,
......@@ -104,6 +106,7 @@ export function BranchToolbarBranchSelector({
104106 isPending: isBranchesSearchPending,
105107 } = useInfiniteQuery(
106108 gitBranchSearchInfiniteQueryOptions({
109 environmentId,
107110 cwd: branchCwd,
108111 query: deferredTrimmedBranchQuery,
109112 }),
......@@ -184,13 +187,13 @@ export function BranchToolbarBranchSelector({
184187 startBranchActionTransition(async () => {
185188 await action().catch(() => undefined);
186189 await queryClient
187 .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) })
190 .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) })
188191 .catch(() => undefined);
189192 });
190193 };
191194
192195 const selectBranch = (branch: GitBranch) => {
193 const api = readNativeApi();
196 const api = readEnvironmentApi(environmentId);
194197 if (!api || !branchCwd || isBranchActionPending) return;
195198
196199 // In new-worktree mode, selecting a branch sets the base branch.
......@@ -248,7 +251,7 @@ export function BranchToolbarBranchSelector({
248251
249252 const createBranch = (rawName: string) => {
250253 const name = rawName.trim();
251 const api = readNativeApi();
254 const api = readEnvironmentApi(environmentId);
252255 if (!api || !branchCwd || !name || isBranchActionPending) return;
253256
254257 setIsBranchMenuOpen(false);
......@@ -302,10 +305,10 @@ export function BranchToolbarBranchSelector({
302305 return;
303306 }
304307 void queryClient.invalidateQueries({
305 queryKey: gitQueryKeys.branches(branchCwd),
308 queryKey: gitQueryKeys.branches(environmentId, branchCwd),
306309 });
307310 },
308 [branchCwd, queryClient],
311 [branchCwd, environmentId, queryClient],
309312 );
310313
311314 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
apps/web/src/components/ChatMarkdown.tsx+2-2
......@@ -22,7 +22,7 @@ import { fnv1a32 } from "../lib/diffRendering";
2222import { LRUCache } from "../lib/lruCache";
2323import { useTheme } from "../hooks/useTheme";
2424import { resolveMarkdownFileLinkTarget } from "../markdown-links";
25import { readNativeApi } from "../nativeApi";
25import { readLocalApi } from "../localApi";
2626
2727class CodeHighlightErrorBoundary extends React.Component<
2828 { fallback: ReactNode; children: ReactNode },
......@@ -253,7 +253,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
253253 onClick={(event) => {
254254 event.preventDefault();
255255 event.stopPropagation();
256 const api = readNativeApi();
256 const api = readLocalApi();
257257 if (api) {
258258 void openInPreferredEditor(api, targetPath);
259259 } else {
apps/web/src/components/ChatView.browser.tsx+330-229
......@@ -4,6 +4,7 @@ import "../index.css";
44import {
55 EventId,
66 ORCHESTRATION_WS_METHODS,
7 EnvironmentId,
78 type MessageId,
89 type OrchestrationEvent,
910 type OrchestrationReadModel,
......@@ -12,11 +13,16 @@ import {
1213 type ServerLifecycleWelcomePayload,
1314 type ThreadId,
1415 type TurnId,
15 type UserInputQuestion,
1616 WS_METHODS,
1717 OrchestrationSessionStatus,
1818 DEFAULT_SERVER_SETTINGS,
1919} from "@t3tools/contracts";
20import {
21 scopedProjectKey,
22 scopedThreadKey,
23 scopeProjectRef,
24 scopeThreadRef,
25} from "@t3tools/client-runtime";
2026import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
2127import { HttpResponse, http, ws } from "msw";
2228import { setupWorker } from "msw/browser";
......@@ -24,19 +30,20 @@ import { page } from "vitest/browser";
2430import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2531import { render } from "vitest-browser-react";
2632
27import { useComposerDraftStore } from "../composerDraftStore";
33import { useComposerDraftStore, DraftId } from "../composerDraftStore";
2834import {
2935 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
3036 type TerminalContextDraft,
3137 removeInlineTerminalContextPlaceholder,
3238} from "../lib/terminalContext";
3339import { isMacPlatform } from "../lib/utils";
34import { __resetNativeApiForTests } from "../nativeApi";
40import { __resetLocalApiForTests } from "../localApi";
3541import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
3642import { getServerConfig } from "../rpc/serverState";
3743import { getRouter } from "../router";
38import { useStore } from "../store";
44import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
3945import { useTerminalStateStore } from "../terminalStateStore";
46import { useUiStateStore } from "../uiStateStore";
4047import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
4148import { estimateTimelineMessageHeight } from "./timelineHeight";
4249import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
......@@ -49,8 +56,13 @@ vi.mock("../lib/gitStatusState", () => ({
4956}));
5057
5158const 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}$/;
5359const 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));
5466const NOW_ISO = "2026-03-04T12:00:00.000Z";
5567const BASE_TIME_MS = Date.parse(NOW_ISO);
5668const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>";
......@@ -129,6 +141,13 @@ function isoAt(offsetSeconds: number): string {
129141
130142function createBaseServerConfig(): ServerConfig {
131143 return {
144 environment: {
145 environmentId: EnvironmentId.makeUnsafe("environment-local"),
146 label: "Local environment",
147 platform: { os: "darwin" as const, arch: "arm64" as const },
148 serverVersion: "0.0.0-test",
149 capabilities: { repositoryIdentity: true },
150 },
132151 cwd: "/repo/project",
133152 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
134153 keybindings: [],
......@@ -314,6 +333,13 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
314333 snapshot,
315334 serverConfig: createBaseServerConfig(),
316335 welcome: {
336 environment: {
337 environmentId: EnvironmentId.makeUnsafe("environment-local"),
338 label: "Local environment",
339 platform: { os: "darwin" as const, arch: "arm64" as const },
340 serverVersion: "0.0.0-test",
341 capabilities: { repositoryIdentity: true },
342 },
317343 cwd: "/repo/project",
318344 projectName: "Project",
319345 bootstrapProjectId: PROJECT_ID,
......@@ -396,6 +422,33 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest
396422 };
397423}
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
399452function sendOrchestrationDomainEvent(event: OrchestrationEvent): void {
400453 rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event);
401454}
......@@ -419,25 +472,72 @@ async function waitForWsClient(): Promise<void> {
419472 );
420473}
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
422509async function waitForAppBootstrap(): Promise<void> {
423510 await vi.waitFor(
424511 () => {
425512 expect(getServerConfig()).not.toBeNull();
426 expect(useStore.getState().bootstrapComplete).toBe(true);
513 expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true);
427514 },
428515 { timeout: 8_000, interval: 16 },
429516 );
430517}
431518
432async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
519async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
433520 await waitForWsClient();
434521 fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId);
435522 sendOrchestrationDomainEvent(
436523 createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence),
437524 );
525}
526
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);
438536 await vi.waitFor(
439537 () => {
440 expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined();
538 expect(useComposerDraftStore.getState().draftThreadsByThreadKey[threadKeyFor(threadId)]).toBe(
539 undefined,
540 );
441541 },
442542 { timeout: 8_000, interval: 16 },
443543 );
......@@ -468,9 +568,12 @@ function withProjectScripts(
468568
469569function setDraftThreadWithoutWorktree(): void {
470570 useComposerDraftStore.setState({
471 draftThreadsByThreadId: {
472 [THREAD_ID]: {
571 draftThreadsByThreadKey: {
572 [THREAD_KEY]: {
573 threadId: THREAD_ID,
574 environmentId: LOCAL_ENVIRONMENT_ID,
473575 projectId: PROJECT_ID,
576 logicalProjectKey: PROJECT_DRAFT_KEY,
474577 createdAt: NOW_ISO,
475578 runtimeMode: "full-access",
476579 interactionMode: "default",
......@@ -479,8 +582,8 @@ function setDraftThreadWithoutWorktree(): void {
479582 envMode: "local",
480583 },
481584 },
482 projectDraftThreadIdByProjectId: {
483 [PROJECT_ID]: THREAD_ID,
585 logicalProjectDraftThreadKeyByLogicalProjectKey: {
586 [PROJECT_DRAFT_KEY]: THREAD_KEY,
484587 },
485588 });
486589}
......@@ -542,51 +645,12 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
542645 };
543646}
544647
545function createSnapshotWithPendingUserInput(options?: {
546 questions?: ReadonlyArray<UserInputQuestion>;
547}): OrchestrationReadModel {
648function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
548649 const snapshot = createSnapshotForTargetUser({
549650 targetMessageId: "msg-user-pending-input-target" as MessageId,
550651 targetText: "question thread",
551652 });
552653
553 const questions =
554 options?.questions ??
555 ([
556 {
557 id: "scope",
558 header: "Scope",
559 question: "What should this change cover?",
560 options: [
561 {
562 label: "Tight",
563 description: "Touch only the footer layout logic.",
564 },
565 {
566 label: "Broad",
567 description: "Also adjust the related composer controls.",
568 },
569 ],
570 multiSelect: false,
571 },
572 {
573 id: "risk",
574 header: "Risk",
575 question: "How aggressive should the imaginary plan be?",
576 options: [
577 {
578 label: "Conservative",
579 description: "Favor reliability and low-risk changes.",
580 },
581 {
582 label: "Balanced",
583 description: "Mix quick wins with one structural improvement.",
584 },
585 ],
586 multiSelect: false,
587 },
588 ] satisfies ReadonlyArray<UserInputQuestion>);
589
590654 return {
591655 ...snapshot,
592656 threads: snapshot.threads.map((thread) =>
......@@ -601,7 +665,38 @@ function createSnapshotWithPendingUserInput(options?: {
601665 summary: "User input requested",
602666 payload: {
603667 requestId: "req-browser-user-input",
604 questions,
668 questions: [
669 {
670 id: "scope",
671 header: "Scope",
672 question: "What should this change cover?",
673 options: [
674 {
675 label: "Tight",
676 description: "Touch only the footer layout logic.",
677 },
678 {
679 label: "Broad",
680 description: "Also adjust the related composer controls.",
681 },
682 ],
683 },
684 {
685 id: "risk",
686 header: "Risk",
687 question: "How aggressive should the imaginary plan be?",
688 options: [
689 {
690 label: "Conservative",
691 description: "Favor reliability and low-risk changes.",
692 },
693 {
694 label: "Balanced",
695 description: "Mix quick wins with one structural improvement.",
696 },
697 ],
698 },
699 ],
605700 },
606701 turnId: null,
607702 sequence: 1,
......@@ -1057,7 +1152,7 @@ async function mountChatView(options: {
10571152
10581153 const router = getRouter(
10591154 createMemoryHistory({
1060 initialEntries: [`/${THREAD_ID}`],
1155 initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`],
10611156 }),
10621157 );
10631158
......@@ -1162,42 +1257,32 @@ describe("ChatView timeline estimator parity (full app)", () => {
11621257 return [];
11631258 },
11641259 });
1165 await __resetNativeApiForTests();
1260 await __resetLocalApiForTests();
11661261 await setViewport(DEFAULT_VIEWPORT);
11671262 localStorage.clear();
11681263 document.body.innerHTML = "";
11691264 wsRequests.length = 0;
11701265 customWsRpcResolver = null;
11711266 useComposerDraftStore.setState({
1172 draftsByThreadId: {},
1173 draftThreadsByThreadId: {},
1174 projectDraftThreadIdByProjectId: {},
1267 draftsByThreadKey: {},
1268 draftThreadsByThreadKey: {},
1269 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
11751270 stickyModelSelectionByProvider: {},
11761271 stickyActiveProvider: null,
11771272 });
11781273 useStore.setState({
1179 projectIds: [],
1180 projectById: {},
1181 threadIds: [],
1182 threadIdsByProjectId: {},
1183 threadShellById: {},
1184 threadSessionById: {},
1185 threadTurnStateById: {},
1186 messageIdsByThreadId: {},
1187 messageByThreadId: {},
1188 activityIdsByThreadId: {},
1189 activityByThreadId: {},
1190 proposedPlanIdsByThreadId: {},
1191 proposedPlanByThreadId: {},
1192 turnDiffIdsByThreadId: {},
1193 turnDiffSummaryByThreadId: {},
1194 sidebarThreadSummaryById: {},
1195 bootstrapComplete: false,
1274 activeEnvironmentId: null,
1275 environmentStateById: {},
1276 });
1277 useUiStateStore.setState({
1278 projectExpandedById: {},
1279 projectOrder: [],
1280 threadLastVisitedAtById: {},
11961281 });
11971282 useTerminalStateStore.persist.clearStorage();
11981283 useTerminalStateStore.setState({
1199 terminalStateByThreadId: {},
1200 terminalLaunchContextByThreadId: {},
1284 terminalStateByThreadKey: {},
1285 terminalLaunchContextByThreadKey: {},
12011286 terminalEventEntriesByKey: {},
12021287 nextTerminalEventId: 1,
12031288 });
......@@ -1241,6 +1326,35 @@ describe("ChatView timeline estimator parity (full app)", () => {
12411326 },
12421327 );
12431328
1329 it("re-expands the bootstrap project using its scoped key", async () => {
1330 useUiStateStore.setState({
1331 projectExpandedById: {
1332 [PROJECT_KEY]: false,
1333 },
1334 projectOrder: [PROJECT_KEY],
1335 threadLastVisitedAtById: {},
1336 });
1337
1338 const mounted = await mountChatView({
1339 viewport: DEFAULT_VIEWPORT,
1340 snapshot: createSnapshotForTargetUser({
1341 targetMessageId: "msg-user-bootstrap-project-expand" as MessageId,
1342 targetText: "bootstrap project expand",
1343 }),
1344 });
1345
1346 try {
1347 await vi.waitFor(
1348 () => {
1349 expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true);
1350 },
1351 { timeout: 8_000, interval: 16 },
1352 );
1353 } finally {
1354 await mounted.cleanup();
1355 }
1356 });
1357
12441358 it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => {
12451359 const userText = "x".repeat(3_200);
12461360 const targetMessageId = "msg-user-target-resize" as MessageId;
......@@ -1438,8 +1552,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
14381552 }
14391553
14401554 useTerminalStateStore.setState({
1441 terminalStateByThreadId: {
1442 [THREAD_ID]: {
1555 terminalStateByThreadKey: {
1556 [THREAD_KEY]: {
14431557 terminalOpen: true,
14441558 terminalHeight: 280,
14451559 terminalIds: ["default"],
......@@ -1449,8 +1563,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
14491563 activeTerminalGroupId: "group-default",
14501564 },
14511565 },
1452 terminalLaunchContextByThreadId: {
1453 [THREAD_ID]: {
1566 terminalLaunchContextByThreadKey: {
1567 [THREAD_KEY]: {
14541568 cwd: "/repo/project",
14551569 worktreePath: null,
14561570 },
......@@ -1696,9 +1810,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
16961810
16971811 it("runs project scripts from local draft threads at the project cwd", async () => {
16981812 useComposerDraftStore.setState({
1699 draftThreadsByThreadId: {
1700 [THREAD_ID]: {
1813 draftThreadsByThreadKey: {
1814 [THREAD_KEY]: {
1815 threadId: THREAD_ID,
1816 environmentId: LOCAL_ENVIRONMENT_ID,
17011817 projectId: PROJECT_ID,
1818 logicalProjectKey: PROJECT_DRAFT_KEY,
17021819 createdAt: NOW_ISO,
17031820 runtimeMode: "full-access",
17041821 interactionMode: "default",
......@@ -1707,8 +1824,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
17071824 envMode: "local",
17081825 },
17091826 },
1710 projectDraftThreadIdByProjectId: {
1711 [PROJECT_ID]: THREAD_ID,
1827 logicalProjectDraftThreadKeyByLogicalProjectKey: {
1828 [PROJECT_DRAFT_KEY]: THREAD_KEY,
17121829 },
17131830 });
17141831
......@@ -1772,9 +1889,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
17721889
17731890 it("runs project scripts from worktree draft threads at the worktree cwd", async () => {
17741891 useComposerDraftStore.setState({
1775 draftThreadsByThreadId: {
1776 [THREAD_ID]: {
1892 draftThreadsByThreadKey: {
1893 [THREAD_KEY]: {
1894 threadId: THREAD_ID,
1895 environmentId: LOCAL_ENVIRONMENT_ID,
17771896 projectId: PROJECT_ID,
1897 logicalProjectKey: PROJECT_DRAFT_KEY,
17781898 createdAt: NOW_ISO,
17791899 runtimeMode: "full-access",
17801900 interactionMode: "default",
......@@ -1783,8 +1903,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
17831903 envMode: "worktree",
17841904 },
17851905 },
1786 projectDraftThreadIdByProjectId: {
1787 [PROJECT_ID]: THREAD_ID,
1906 logicalProjectDraftThreadKeyByLogicalProjectKey: {
1907 [PROJECT_DRAFT_KEY]: THREAD_KEY,
17881908 },
17891909 });
17901910
......@@ -1835,9 +1955,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
18351955
18361956 it("lets the server own setup after preparing a pull request worktree thread", async () => {
18371957 useComposerDraftStore.setState({
1838 draftThreadsByThreadId: {
1839 [THREAD_ID]: {
1958 draftThreadsByThreadKey: {
1959 [THREAD_KEY]: {
1960 threadId: THREAD_ID,
1961 environmentId: LOCAL_ENVIRONMENT_ID,
18401962 projectId: PROJECT_ID,
1963 logicalProjectKey: PROJECT_DRAFT_KEY,
18411964 createdAt: NOW_ISO,
18421965 runtimeMode: "full-access",
18431966 interactionMode: "default",
......@@ -1846,8 +1969,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
18461969 envMode: "local",
18471970 },
18481971 },
1849 projectDraftThreadIdByProjectId: {
1850 [PROJECT_ID]: THREAD_ID,
1972 logicalProjectDraftThreadKeyByLogicalProjectKey: {
1973 [PROJECT_DRAFT_KEY]: THREAD_KEY,
18511974 },
18521975 });
18531976
......@@ -1957,12 +2080,15 @@ describe("ChatView timeline estimator parity (full app)", () => {
19572080
19582081 it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => {
19592082 useTerminalStateStore.setState({
1960 terminalStateByThreadId: {},
2083 terminalStateByThreadKey: {},
19612084 });
19622085 useComposerDraftStore.setState({
1963 draftThreadsByThreadId: {
1964 [THREAD_ID]: {
2086 draftThreadsByThreadKey: {
2087 [THREAD_KEY]: {
2088 threadId: THREAD_ID,
2089 environmentId: LOCAL_ENVIRONMENT_ID,
19652090 projectId: PROJECT_ID,
2091 logicalProjectKey: PROJECT_DRAFT_KEY,
19662092 createdAt: NOW_ISO,
19672093 runtimeMode: "full-access",
19682094 interactionMode: "default",
......@@ -1971,8 +2097,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
19712097 envMode: "worktree",
19722098 },
19732099 },
1974 projectDraftThreadIdByProjectId: {
1975 [PROJECT_ID]: THREAD_ID,
2100 logicalProjectDraftThreadKeyByLogicalProjectKey: {
2101 [PROJECT_DRAFT_KEY]: THREAD_KEY,
19762102 },
19772103 });
19782104
......@@ -1998,7 +2124,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
19982124 });
19992125
20002126 try {
2001 useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it");
2127 useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
20022128 await waitForLayout();
20032129
20042130 const sendButton = await waitForSendButton();
......@@ -2057,12 +2183,15 @@ describe("ChatView timeline estimator parity (full app)", () => {
20572183
20582184 it("shows the send state once bootstrap dispatch is in flight", async () => {
20592185 useTerminalStateStore.setState({
2060 terminalStateByThreadId: {},
2186 terminalStateByThreadKey: {},
20612187 });
20622188 useComposerDraftStore.setState({
2063 draftThreadsByThreadId: {
2064 [THREAD_ID]: {
2189 draftThreadsByThreadKey: {
2190 [THREAD_KEY]: {
2191 threadId: THREAD_ID,
2192 environmentId: LOCAL_ENVIRONMENT_ID,
20652193 projectId: PROJECT_ID,
2194 logicalProjectKey: PROJECT_DRAFT_KEY,
20662195 createdAt: NOW_ISO,
20672196 runtimeMode: "full-access",
20682197 interactionMode: "default",
......@@ -2071,8 +2200,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
20712200 envMode: "worktree",
20722201 },
20732202 },
2074 projectDraftThreadIdByProjectId: {
2075 [PROJECT_ID]: THREAD_ID,
2203 logicalProjectDraftThreadKeyByLogicalProjectKey: {
2204 [PROJECT_DRAFT_KEY]: THREAD_KEY,
20762205 },
20772206 });
20782207
......@@ -2101,7 +2230,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
21012230 });
21022231
21032232 try {
2104 useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it");
2233 useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
21052234 await waitForLayout();
21062235
21072236 const sendButton = await waitForSendButton();
......@@ -2193,7 +2322,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
21932322 const removedLabel = "Terminal 1 lines 1-2";
21942323 const addedLabel = "Terminal 2 lines 9-10";
21952324 useComposerDraftStore.getState().addTerminalContext(
2196 THREAD_ID,
2325 THREAD_REF,
21972326 createTerminalContext({
21982327 id: "ctx-removed",
21992328 terminalLabel: "Terminal 1",
......@@ -2220,21 +2349,21 @@ describe("ChatView timeline estimator parity (full app)", () => {
22202349 );
22212350
22222351 const store = useComposerDraftStore.getState();
2223 const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? "";
2352 const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? "";
22242353 const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0);
2225 store.setPrompt(THREAD_ID, nextPrompt.prompt);
2226 store.removeTerminalContext(THREAD_ID, "ctx-removed");
2354 store.setPrompt(THREAD_REF, nextPrompt.prompt);
2355 store.removeTerminalContext(THREAD_REF, "ctx-removed");
22272356
22282357 await vi.waitFor(
22292358 () => {
2230 expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined();
2359 expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined();
22312360 expect(document.body.textContent).not.toContain(removedLabel);
22322361 },
22332362 { timeout: 8_000, interval: 16 },
22342363 );
22352364
22362365 useComposerDraftStore.getState().addTerminalContext(
2237 THREAD_ID,
2366 THREAD_REF,
22382367 createTerminalContext({
22392368 id: "ctx-added",
22402369 terminalLabel: "Terminal 2",
......@@ -2246,7 +2375,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
22462375
22472376 await vi.waitFor(
22482377 () => {
2249 const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID];
2378 const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY];
22502379 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]);
22512380 expect(document.body.textContent).toContain(addedLabel);
22522381 expect(document.body.textContent).not.toContain(removedLabel);
......@@ -2261,7 +2390,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
22612390 it("disables send when the composer only contains an expired terminal pill", async () => {
22622391 const expiredLabel = "Terminal 1 line 4";
22632392 useComposerDraftStore.getState().addTerminalContext(
2264 THREAD_ID,
2393 THREAD_REF,
22652394 createTerminalContext({
22662395 id: "ctx-expired-only",
22672396 terminalLabel: "Terminal 1",
......@@ -2297,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
22972426 it("warns when sending text while omitting expired terminal pills", async () => {
22982427 const expiredLabel = "Terminal 1 line 4";
22992428 useComposerDraftStore.getState().addTerminalContext(
2300 THREAD_ID,
2429 THREAD_REF,
23012430 createTerminalContext({
23022431 id: "ctx-expired-send-warning",
23032432 terminalLabel: "Terminal 1",
......@@ -2308,7 +2437,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
23082437 );
23092438 useComposerDraftStore
23102439 .getState()
2311 .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`);
2440 .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`);
23122441
23132442 const mounted = await mountChatView({
23142443 viewport: DEFAULT_VIEWPORT,
......@@ -2448,7 +2577,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
24482577 }
24492578 });
24502579
2451 it("keeps the new thread selected after clicking the new-thread button", async () => {
2580 it("canonicalizes promoted draft threads to the server thread route", async () => {
24522581 const mounted = await mountChatView({
24532582 viewport: DEFAULT_VIEWPORT,
24542583 snapshot: createSnapshotForTargetUser({
......@@ -2470,21 +2599,34 @@ describe("ChatView timeline estimator parity (full app)", () => {
24702599 (path) => UUID_ROUTE_RE.test(path),
24712600 "Route should have changed to a new draft thread UUID.",
24722601 );
2473 const newThreadId = newThreadPath.slice(1) as ThreadId;
2602 const newDraftId = draftIdFromPath(newThreadPath);
2603 const newThreadId = draftThreadIdFor(newDraftId);
24742604
24752605 // The composer editor should be present for the new draft thread.
24762606 await waitForComposerEditor();
24772607
2478 // Simulate the steady-state promotion path: the server emits
2479 // `thread.created`, the client materializes the thread incrementally,
2480 // and the draft is cleared by live batch effects.
2481 await promoteDraftThreadViaDomainEvent(newThreadId);
2608 // `thread.created` should only mark the draft as promoting; it should
2609 // not navigate away until the server thread has actual runtime state.
2610 await materializePromotedDraftThreadViaDomainEvent(newThreadId);
2611 expect(mounted.router.state.location.pathname).toBe(newThreadPath);
2612 await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument();
2613
2614 // Once the server thread starts, the route should canonicalize.
2615 await startPromotedServerThreadViaDomainEvent(newThreadId);
2616 await vi.waitFor(
2617 () => {
2618 expect(useComposerDraftStore.getState().draftThreadsByThreadKey[newDraftId]).toBe(
2619 undefined,
2620 );
2621 },
2622 { timeout: 8_000, interval: 16 },
2623 );
24822624
2483 // The route should still be on the new thread — not redirected away.
2625 // The route should switch to the canonical server thread path.
24842626 await waitForURL(
24852627 mounted.router,
2486 (path) => path === newThreadPath,
2487 "New thread should remain selected after server thread promotion clears the draft.",
2628 (path) => path === serverThreadPath(newThreadId),
2629 "Promoted drafts should canonicalize to the server thread route.",
24882630 );
24892631
24902632 // The empty thread view and composer should still be visible.
......@@ -2497,6 +2639,48 @@ describe("ChatView timeline estimator parity (full app)", () => {
24972639 }
24982640 });
24992641
2642 it("canonicalizes stale promoted draft routes to the server thread route", async () => {
2643 const mounted = await mountChatView({
2644 viewport: DEFAULT_VIEWPORT,
2645 snapshot: createSnapshotForTargetUser({
2646 targetMessageId: "msg-user-draft-hydration-race-test" as MessageId,
2647 targetText: "draft hydration race test",
2648 }),
2649 });
2650
2651 try {
2652 const newThreadButton = page.getByTestId("new-thread-button");
2653 await expect.element(newThreadButton).toBeInTheDocument();
2654
2655 await newThreadButton.click();
2656
2657 const newThreadPath = await waitForURL(
2658 mounted.router,
2659 (path) => UUID_ROUTE_RE.test(path),
2660 "Route should have changed to a new draft thread UUID.",
2661 );
2662 const newDraftId = draftIdFromPath(newThreadPath);
2663 const newThreadId = draftThreadIdFor(newDraftId);
2664
2665 await promoteDraftThreadViaDomainEvent(newThreadId);
2666
2667 await mounted.router.navigate({
2668 to: "/draft/$draftId",
2669 params: { draftId: newDraftId },
2670 });
2671
2672 await waitForURL(
2673 mounted.router,
2674 (path) => path === serverThreadPath(newThreadId),
2675 "Stale promoted draft routes should canonicalize to the server thread path.",
2676 );
2677
2678 await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument();
2679 } finally {
2680 await mounted.cleanup();
2681 }
2682 });
2683
25002684 it("snapshots sticky codex settings into a new draft thread", async () => {
25012685 useComposerDraftStore.setState({
25022686 stickyModelSelectionByProvider: {
......@@ -2531,9 +2715,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
25312715 (path) => UUID_ROUTE_RE.test(path),
25322716 "Route should have changed to a new draft thread UUID.",
25332717 );
2534 const newThreadId = newThreadPath.slice(1) as ThreadId;
2718 const newDraftId = draftIdFromPath(newThreadPath);
25352719
2536 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({
2720 expect(composerDraftFor(newDraftId)).toMatchObject({
25372721 modelSelectionByProvider: {
25382722 codex: {
25392723 provider: "codex",
......@@ -2584,9 +2768,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
25842768 (path) => UUID_ROUTE_RE.test(path),
25852769 "Route should have changed to a new sticky claude draft thread UUID.",
25862770 );
2587 const newThreadId = newThreadPath.slice(1) as ThreadId;
2771 const newDraftId = draftIdFromPath(newThreadPath);
25882772
2589 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({
2773 expect(composerDraftFor(newDraftId)).toMatchObject({
25902774 modelSelectionByProvider: {
25912775 claudeAgent: {
25922776 provider: "claudeAgent",
......@@ -2624,9 +2808,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
26242808 (path) => UUID_ROUTE_RE.test(path),
26252809 "Route should have changed to a new draft thread UUID.",
26262810 );
2627 const newThreadId = newThreadPath.slice(1) as ThreadId;
2811 const newDraftId = draftIdFromPath(newThreadPath);
26282812
2629 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined();
2813 expect(composerDraftFor(newDraftId)).toBe(undefined);
26302814 } finally {
26312815 await mounted.cleanup();
26322816 }
......@@ -2666,9 +2850,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
26662850 (path) => UUID_ROUTE_RE.test(path),
26672851 "Route should have changed to a sticky draft thread UUID.",
26682852 );
2669 const threadId = threadPath.slice(1) as ThreadId;
2853 const draftId = draftIdFromPath(threadPath);
26702854
2671 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
2855 expect(composerDraftFor(draftId)).toMatchObject({
26722856 modelSelectionByProvider: {
26732857 codex: {
26742858 provider: "codex",
......@@ -2681,7 +2865,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
26812865 activeProvider: "codex",
26822866 });
26832867
2684 useComposerDraftStore.getState().setModelSelection(threadId, {
2868 useComposerDraftStore.getState().setModelSelection(draftId, {
26852869 provider: "codex",
26862870 model: "gpt-5.4",
26872871 options: {
......@@ -2697,7 +2881,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
26972881 (path) => path === threadPath,
26982882 "New-thread should reuse the existing project draft thread.",
26992883 );
2700 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
2884 expect(composerDraftFor(draftId)).toMatchObject({
27012885 modelSelectionByProvider: {
27022886 codex: {
27032887 provider: "codex",
......@@ -2804,7 +2988,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
28042988 (path) => UUID_ROUTE_RE.test(path),
28052989 "Route should have changed to a promoted draft thread UUID.",
28062990 );
2807 const promotedThreadId = promotedThreadPath.slice(1) as ThreadId;
2991 const promotedDraftId = draftIdFromPath(promotedThreadPath);
2992 const promotedThreadId = draftThreadIdFor(promotedDraftId);
28082993
28092994 await promoteDraftThreadViaDomainEvent(promotedThreadId);
28102995
......@@ -2925,70 +3110,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
29253110 }
29263111 });
29273112
2928 it("does not trigger numeric option shortcuts while the composer is focused", async () => {
3113 it("submits pending user input after the final option selection resolves the draft answers", async () => {
29293114 const mounted = await mountChatView({
2930 viewport: WIDE_FOOTER_VIEWPORT,
3115 viewport: DEFAULT_VIEWPORT,
29313116 snapshot: createSnapshotWithPendingUserInput(),
2932 });
2933
2934 try {
2935 const composerEditor = await waitForComposerEditor();
2936 composerEditor.focus();
2937
2938 const event = new KeyboardEvent("keydown", {
2939 key: "2",
2940 bubbles: true,
2941 cancelable: true,
2942 });
2943 composerEditor.dispatchEvent(event);
2944 await waitForLayout();
2945
2946 expect(event.defaultPrevented).toBe(false);
2947 expect(document.body.textContent).toContain("What should this change cover?");
2948 expect(document.body.textContent).not.toContain(
2949 "How aggressive should the imaginary plan be?",
2950 );
2951 await waitForButtonByText("Next question");
2952 } finally {
2953 await mounted.cleanup();
2954 }
2955 });
2956
2957 it("submits multi-select questionnaire answers as arrays", async () => {
2958 const mounted = await mountChatView({
2959 viewport: WIDE_FOOTER_VIEWPORT,
2960 snapshot: createSnapshotWithPendingUserInput({
2961 questions: [
2962 {
2963 id: "scope",
2964 header: "Scope",
2965 question: "Which areas should this change cover?",
2966 options: [
2967 {
2968 label: "Server",
2969 description: "Touch server orchestration.",
2970 },
2971 {
2972 label: "Web",
2973 description: "Touch the browser UI.",
2974 },
2975 ],
2976 multiSelect: true,
2977 },
2978 {
2979 id: "risk",
2980 header: "Risk",
2981 question: "How aggressive should the imaginary plan be?",
2982 options: [
2983 {
2984 label: "Balanced",
2985 description: "Mix quick wins with one structural improvement.",
2986 },
2987 ],
2988 multiSelect: false,
2989 },
2990 ],
2991 }),
29923117 resolveRpc: (body) => {
29933118 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
29943119 return {
......@@ -3000,37 +3125,11 @@ describe("ChatView timeline estimator parity (full app)", () => {
30003125 });
30013126
30023127 try {
3003 const serverOption = await waitForButtonContainingText("Server");
3004 serverOption.click();
3005 await waitForLayout();
3006
3007 expect(document.body.textContent).toContain("Which areas should this change cover?");
3008
3009 const webOption = await waitForButtonContainingText("Web");
3010 webOption.click();
3011 await waitForLayout();
3012
3013 expect(document.body.textContent).toContain("Which areas should this change cover?");
3014
3015 const nextButton = await waitForButtonByText("Next question");
3016 expect(nextButton.disabled).toBe(false);
3017 nextButton.click();
3018
3019 await vi.waitFor(
3020 () => {
3021 expect(document.body.textContent).toContain(
3022 "How aggressive should the imaginary plan be?",
3023 );
3024 },
3025 { timeout: 8_000, interval: 16 },
3026 );
3027
3028 const balancedOption = await waitForButtonContainingText("Balanced");
3029 balancedOption.click();
3128 const firstOption = await waitForButtonContainingText("Tight");
3129 firstOption.click();
30303130
3031 const submitButton = await waitForButtonByText("Submit answers");
3032 expect(submitButton.disabled).toBe(false);
3033 submitButton.click();
3131 const finalOption = await waitForButtonContainingText("Conservative");
3132 finalOption.click();
30343133
30353134 await vi.waitFor(
30363135 () => {
......@@ -3042,6 +3141,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
30423141 | {
30433142 _tag: string;
30443143 type?: string;
3144 requestId?: string;
30453145 answers?: Record<string, unknown>;
30463146 }
30473147 | undefined;
......@@ -3049,9 +3149,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
30493149 expect(dispatchRequest).toMatchObject({
30503150 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
30513151 type: "thread.user-input.respond",
3152 requestId: "req-browser-user-input",
30523153 answers: {
3053 scope: ["Server", "Web"],
3054 risk: "Balanced",
3154 scope: "Tight",
3155 risk: "Conservative",
30553156 },
30563157 });
30573158 },
apps/web/src/components/ChatView.logic.test.ts+57-7
......@@ -1,6 +1,7 @@
1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
1import { scopeThreadRef } from "@t3tools/client-runtime";
2import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
23import { afterEach, describe, expect, it, vi } from "vitest";
3import { useStore } from "../store";
4import { type EnvironmentState, useStore } from "../store";
45import { type Thread } from "../types";
56
67import {
......@@ -10,9 +11,12 @@ import {
1011 deriveComposerSendState,
1112 hasServerAcknowledgedLocalDispatch,
1213 reconcileMountedTerminalThreadIds,
14 shouldWriteThreadErrorToCurrentServerThread,
1315 waitForStartedServerThread,
1416} from "./ChatView.logic";
1517
18const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
19
1620describe("deriveComposerSendState", () => {
1721 it("treats expired terminal pills as non-sendable content", () => {
1822 const state = deriveComposerSendState({
......@@ -170,6 +174,36 @@ describe("reconcileMountedTerminalThreadIds", () => {
170174 });
171175});
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
173207const makeThread = (input?: {
174208 id?: ThreadId;
175209 latestTurn?: {
......@@ -181,6 +215,7 @@ const makeThread = (input?: {
181215 } | null;
182216}): Thread => ({
183217 id: input?.id ?? ThreadId.makeUnsafe("thread-1"),
218 environmentId: localEnvironmentId,
184219 codexThreadId: null,
185220 projectId: ProjectId.makeUnsafe("project-1"),
186221 title: "Thread",
......@@ -208,11 +243,12 @@ const makeThread = (input?: {
208243
209244function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) {
210245 const projectId = ProjectId.makeUnsafe("project-1");
211 useStore.setState({
246 const environmentState: EnvironmentState = {
212247 projectIds: [projectId],
213248 projectById: {
214249 [projectId]: {
215250 id: projectId,
251 environmentId: localEnvironmentId,
216252 name: "Project",
217253 cwd: "/tmp/project",
218254 defaultModelSelection: {
......@@ -233,6 +269,7 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>)
233269 thread.id,
234270 {
235271 id: thread.id,
272 environmentId: thread.environmentId,
236273 codexThreadId: thread.codexThreadId,
237274 projectId: thread.projectId,
238275 title: thread.title,
......@@ -301,6 +338,12 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>)
301338 ),
302339 sidebarThreadSummaryById: {},
303340 bootstrapComplete: true,
341 };
342 useStore.setState({
343 activeEnvironmentId: localEnvironmentId,
344 environmentStateById: {
345 [localEnvironmentId]: environmentState,
346 },
304347 });
305348}
306349
......@@ -326,14 +369,16 @@ describe("waitForStartedServerThread", () => {
326369 }),
327370 ]);
328371
329 await expect(waitForStartedServerThread(threadId)).resolves.toBe(true);
372 await expect(
373 waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)),
374 ).resolves.toBe(true);
330375 });
331376
332377 it("waits for the thread to start via subscription updates", async () => {
333378 const threadId = ThreadId.makeUnsafe("thread-wait");
334379 setStoreThreads([makeThread({ id: threadId })]);
335380
336 const promise = waitForStartedServerThread(threadId, 500);
381 const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500);
337382
338383 setStoreThreads([
339384 makeThread({
......@@ -376,7 +421,9 @@ describe("waitForStartedServerThread", () => {
376421 return originalSubscribe(listener);
377422 });
378423
379 await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true);
424 await expect(
425 waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500),
426 ).resolves.toBe(true);
380427 });
381428
382429 it("returns false after the timeout when the thread never starts", async () => {
......@@ -384,7 +431,7 @@ describe("waitForStartedServerThread", () => {
384431
385432 const threadId = ThreadId.makeUnsafe("thread-timeout");
386433 setStoreThreads([makeThread({ id: threadId })]);
387 const promise = waitForStartedServerThread(threadId, 500);
434 const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500);
388435
389436 await vi.advanceTimersByTimeAsync(500);
390437
......@@ -414,6 +461,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
414461 it("does not clear local dispatch before server state changes", () => {
415462 const localDispatch = createLocalDispatchSnapshot({
416463 id: ThreadId.makeUnsafe("thread-1"),
464 environmentId: localEnvironmentId,
417465 codexThreadId: null,
418466 projectId,
419467 title: "Thread",
......@@ -450,6 +498,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
450498 it("clears local dispatch when a new turn is already settled", () => {
451499 const localDispatch = createLocalDispatchSnapshot({
452500 id: ThreadId.makeUnsafe("thread-1"),
501 environmentId: localEnvironmentId,
453502 codexThreadId: null,
454503 projectId,
455504 title: "Thread",
......@@ -495,6 +544,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
495544 it("clears local dispatch when the session changes without an observed running phase", () => {
496545 const localDispatch = createLocalDispatchSnapshot({
497546 id: ThreadId.makeUnsafe("thread-1"),
547 environmentId: localEnvironmentId,
498548 codexThreadId: null,
499549 projectId,
500550 title: "Thread",
apps/web/src/components/ChatView.logic.ts+36-9
......@@ -1,9 +1,16 @@
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";
29import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";
310import { randomUUID } from "~/lib/utils";
411import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
512import { Schema } from "effect";
6import { selectThreadById, useStore } from "../store";
13import { selectThreadByRef, useStore } from "../store";
714import {
815 filterTerminalContextsWithText,
916 stripInlineTerminalContextPlaceholders,
......@@ -24,6 +31,7 @@ export function buildLocalDraftThread(
2431): Thread {
2532 return {
2633 id: threadId,
34 environmentId: draftThread.environmentId,
2735 codexThreadId: null,
2836 projectId: draftThread.projectId,
2937 title: "New thread",
......@@ -44,13 +52,32 @@ export function buildLocalDraftThread(
4452 };
4553}
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
4774export function reconcileMountedTerminalThreadIds(input: {
48 currentThreadIds: ReadonlyArray<ThreadId>;
49 openThreadIds: ReadonlyArray<ThreadId>;
50 activeThreadId: ThreadId | null;
75 currentThreadIds: ReadonlyArray<string>;
76 openThreadIds: ReadonlyArray<string>;
77 activeThreadId: string | null;
5178 activeThreadTerminalOpen: boolean;
5279 maxHiddenThreadCount?: number;
53}): ThreadId[] {
80}): string[] {
5481 const openThreadIdSet = new Set(input.openThreadIds);
5582 const hiddenThreadIds = input.currentThreadIds.filter(
5683 (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId),
......@@ -199,10 +226,10 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean {
199226}
200227
201228export async function waitForStartedServerThread(
202 threadId: ThreadId,
229 threadRef: ScopedThreadRef,
203230 timeoutMs = 1_000,
204231): Promise<boolean> {
205 const getThread = () => selectThreadById(threadId)(useStore.getState());
232 const getThread = () => selectThreadByRef(useStore.getState(), threadRef);
206233 const thread = getThread();
207234
208235 if (threadHasStarted(thread)) {
......@@ -225,7 +252,7 @@ export async function waitForStartedServerThread(
225252 };
226253
227254 const unsubscribe = useStore.subscribe((state) => {
228 if (!threadHasStarted(selectThreadById(threadId)(state))) {
255 if (!threadHasStarted(selectThreadByRef(state, threadRef))) {
229256 return;
230257 }
231258 finish(true);
apps/web/src/components/ChatView.tsx+522-254
......@@ -2,6 +2,7 @@ import {
22 type ApprovalRequestId,
33 DEFAULT_MODEL_BY_PROVIDER,
44 type ClaudeCodeEffort,
5 type EnvironmentId,
56 type MessageId,
67 type ModelSelection,
78 type ProjectScript,
......@@ -12,6 +13,7 @@ import {
1213 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1314 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1415 type ServerProvider,
16 type ScopedThreadRef,
1517 type ThreadId,
1618 type TurnId,
1719 type KeybindingCommand,
......@@ -20,6 +22,12 @@ import {
2022 RuntimeMode,
2123 TerminalOpenInput,
2224} from "@t3tools/contracts";
25import {
26 parseScopedThreadKey,
27 scopedThreadKey,
28 scopeProjectRef,
29 scopeThreadRef,
30} from "@t3tools/client-runtime";
2331import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
2432import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
2533import { truncate } from "@t3tools/shared/String";
......@@ -27,9 +35,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
2735import { useQuery } from "@tanstack/react-query";
2836import { useDebouncedValue } from "@tanstack/react-pacer";
2937import { useNavigate, useSearch } from "@tanstack/react-router";
38import { useShallow } from "zustand/react/shallow";
3039import { useGitStatus } from "~/lib/gitStatusState";
3140import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
41import { readEnvironmentApi } from "../environmentApi";
3242import { isElectron } from "../env";
43import { readLocalApi } from "../localApi";
3344import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
3445import {
3546 clampCollapsedComposerCursor,
......@@ -64,8 +75,8 @@ import {
6475 togglePendingUserInputOptionSelection,
6576 type PendingUserInputDraftAnswer,
6677} from "../pendingUserInput";
67import { useStore } from "../store";
68import { createThreadSelector } from "../storeSelectors";
78import { selectThreadsAcrossEnvironments, useStore } from "../store";
79import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
6980import { useUiStateStore } from "../uiStateStore";
7081import {
7182 buildPlanImplementationThreadTitle,
......@@ -114,8 +125,7 @@ import {
114125 projectScriptIdFromCommand,
115126} from "~/projectScripts";
116127import { SidebarTrigger } from "./ui/sidebar";
117import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
118import { readNativeApi } from "~/nativeApi";
128import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";
119129import {
120130 getProviderModelCapabilities,
121131 getProviderModels,
......@@ -124,6 +134,8 @@ import {
124134import { useSettings } from "../hooks/useSettings";
125135import { resolveAppModelSelection } from "../modelSelection";
126136import { isTerminalFocused } from "../lib/terminalFocus";
137import { deriveLogicalProjectKey } from "../logicalProject";
138import { buildDraftThreadRouteParams } from "../threadRoutes";
127139import {
128140 type ComposerImageAttachment,
129141 type DraftThreadEnvMode,
......@@ -131,6 +143,7 @@ import {
131143 useComposerDraftStore,
132144 useEffectiveComposerModelState,
133145 useComposerThreadDraft,
146 type DraftId,
134147} from "../composerDraftStore";
135148import {
136149 appendTerminalContextsToPrompt,
......@@ -187,6 +200,7 @@ import {
187200 reconcileMountedTerminalThreadIds,
188201 revokeBlobPreviewUrl,
189202 revokeUserMessagePreviewUrls,
203 shouldWriteThreadErrorToCurrentServerThread,
190204 threadHasStarted,
191205 waitForStartedServerThread,
192206} from "./ChatView.logic";
......@@ -203,6 +217,7 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
203217const IMAGE_ONLY_BOOTSTRAP_PROMPT =
204218 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
205219const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
220const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
206221const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
207222const EMPTY_PROVIDERS: ServerProvider[] = [];
208223const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
......@@ -210,26 +225,115 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw
210225type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
211226
212227function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] {
213 const threadShellById = useStore((state) => state.threadShellById);
214 const proposedPlanIdsByThreadId = useStore((state) => state.proposedPlanIdsByThreadId);
215 const proposedPlanByThreadId = useStore((state) => state.proposedPlanByThreadId);
228 return useStore(
229 useMemo(() => {
230 let previousThreadIds: readonly ThreadId[] = [];
231 let previousResult: ThreadPlanCatalogEntry[] = [];
232 let previousEntries = new Map<
233 ThreadId,
234 {
235 shell: object | null;
236 proposedPlanIds: readonly string[] | undefined;
237 proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined;
238 entry: ThreadPlanCatalogEntry;
239 }
240 >();
241
242 return (state) => {
243 const sameThreadIds =
244 previousThreadIds.length === threadIds.length &&
245 previousThreadIds.every((id, index) => id === threadIds[index]);
246 const nextEntries = new Map<
247 ThreadId,
248 {
249 shell: object | null;
250 proposedPlanIds: readonly string[] | undefined;
251 proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined;
252 entry: ThreadPlanCatalogEntry;
253 }
254 >();
255 const nextResult: ThreadPlanCatalogEntry[] = [];
256 let changed = !sameThreadIds;
257
258 for (const threadId of threadIds) {
259 let shell: object | undefined;
260 let proposedPlanIds: readonly string[] | undefined;
261 let proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined;
262
263 for (const environmentState of Object.values(state.environmentStateById)) {
264 const matchedShell = environmentState.threadShellById[threadId];
265 if (!matchedShell) {
266 continue;
267 }
268 shell = matchedShell;
269 proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId];
270 proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as
271 | Record<string, Thread["proposedPlans"][number]>
272 | undefined;
273 break;
274 }
216275
217 return useMemo(
218 () =>
219 threadIds.flatMap((threadId) => {
220 if (!threadShellById[threadId]) {
221 return [];
276 if (!shell) {
277 const previous = previousEntries.get(threadId);
278 if (
279 previous &&
280 previous.shell === null &&
281 previous.proposedPlanIds === undefined &&
282 previous.proposedPlansById === undefined
283 ) {
284 nextEntries.set(threadId, previous);
285 continue;
286 }
287 changed = true;
288 nextEntries.set(threadId, {
289 shell: null,
290 proposedPlanIds: undefined,
291 proposedPlansById: undefined,
292 entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS },
293 });
294 continue;
295 }
296
297 const previous = previousEntries.get(threadId);
298 if (
299 previous &&
300 previous.shell === shell &&
301 previous.proposedPlanIds === proposedPlanIds &&
302 previous.proposedPlansById === proposedPlansById
303 ) {
304 nextEntries.set(threadId, previous);
305 nextResult.push(previous.entry);
306 continue;
307 }
308
309 changed = true;
310 const proposedPlans =
311 proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById
312 ? proposedPlanIds.flatMap((planId) => {
313 const proposedPlan = proposedPlansById?.[planId];
314 return proposedPlan ? [proposedPlan] : [];
315 })
316 : EMPTY_PROPOSED_PLANS;
317 const entry = { id: threadId, proposedPlans };
318 nextEntries.set(threadId, {
319 shell,
320 proposedPlanIds,
321 proposedPlansById,
322 entry,
323 });
324 nextResult.push(entry);
222325 }
223326
224 const proposedPlans =
225 proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => {
226 const plan = proposedPlanByThreadId[threadId]?.[planId];
227 return plan ? [plan] : [];
228 }) ?? [];
327 if (!changed && previousResult.length === nextResult.length) {
328 return previousResult;
329 }
229330
230 return [{ id: threadId, proposedPlans }];
231 }),
232 [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById],
331 previousThreadIds = threadIds;
332 previousEntries = nextEntries;
333 previousResult = nextResult;
334 return nextResult;
335 };
336 }, [threadIds]),
233337 );
234338}
235339
......@@ -278,9 +382,19 @@ const terminalContextIdListsEqual = (
278382): boolean =>
279383 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
280384
281interface ChatViewProps {
282 threadId: ThreadId;
283}
385type ChatViewProps =
386 | {
387 environmentId: EnvironmentId;
388 threadId: ThreadId;
389 routeKind: "server";
390 draftId?: never;
391 }
392 | {
393 environmentId: EnvironmentId;
394 threadId: ThreadId;
395 routeKind: "draft";
396 draftId: DraftId;
397 };
284398
285399interface TerminalLaunchContext {
286400 threadId: ThreadId;
......@@ -358,6 +472,7 @@ function useLocalDispatchState(input: {
358472}
359473
360474interface PersistentThreadTerminalDrawerProps {
475 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
361476 threadId: ThreadId;
362477 visible: boolean;
363478 launchContext: PersistentTerminalLaunchContext | null;
......@@ -369,6 +484,7 @@ interface PersistentThreadTerminalDrawerProps {
369484}
370485
371486function PersistentThreadTerminalDrawer({
487 threadRef,
372488 threadId,
373489 visible,
374490 launchContext,
......@@ -378,19 +494,16 @@ function PersistentThreadTerminalDrawer({
378494 closeShortcutLabel,
379495 onAddTerminalContext,
380496}: PersistentThreadTerminalDrawerProps) {
381 const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId]));
382 const draftThread = useComposerDraftStore(
383 (store) => store.draftThreadsByThreadId[threadId] ?? null,
384 );
385 const project = useStore((state) =>
386 serverThread?.projectId
387 ? state.projectById[serverThread.projectId]
388 : draftThread?.projectId
389 ? state.projectById[draftThread.projectId]
390 : undefined,
391 );
497 const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
498 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
499 const projectRef = serverThread
500 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
501 : draftThread
502 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
503 : null;
504 const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef]));
392505 const terminalState = useTerminalStateStore((state) =>
393 selectThreadTerminalState(state.terminalStateByThreadId, threadId),
506 selectThreadTerminalState(state.terminalStateByThreadKey, threadRef),
394507 );
395508 const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight);
396509 const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal);
......@@ -436,32 +549,32 @@ function PersistentThreadTerminalDrawer({
436549
437550 const setTerminalHeight = useCallback(
438551 (height: number) => {
439 storeSetTerminalHeight(threadId, height);
552 storeSetTerminalHeight(threadRef, height);
440553 },
441 [storeSetTerminalHeight, threadId],
554 [storeSetTerminalHeight, threadRef],
442555 );
443556
444557 const splitTerminal = useCallback(() => {
445 storeSplitTerminal(threadId, `terminal-${randomUUID()}`);
558 storeSplitTerminal(threadRef, `terminal-${randomUUID()}`);
446559 bumpFocusRequestId();
447 }, [bumpFocusRequestId, storeSplitTerminal, threadId]);
560 }, [bumpFocusRequestId, storeSplitTerminal, threadRef]);
448561
449562 const createNewTerminal = useCallback(() => {
450 storeNewTerminal(threadId, `terminal-${randomUUID()}`);
563 storeNewTerminal(threadRef, `terminal-${randomUUID()}`);
451564 bumpFocusRequestId();
452 }, [bumpFocusRequestId, storeNewTerminal, threadId]);
565 }, [bumpFocusRequestId, storeNewTerminal, threadRef]);
453566
454567 const activateTerminal = useCallback(
455568 (terminalId: string) => {
456 storeSetActiveTerminal(threadId, terminalId);
569 storeSetActiveTerminal(threadRef, terminalId);
457570 bumpFocusRequestId();
458571 },
459 [bumpFocusRequestId, storeSetActiveTerminal, threadId],
572 [bumpFocusRequestId, storeSetActiveTerminal, threadRef],
460573 );
461574
462575 const closeTerminal = useCallback(
463576 (terminalId: string) => {
464 const api = readNativeApi();
577 const api = readEnvironmentApi(threadRef.environmentId);
465578 if (!api) return;
466579 const isFinalTerminal = terminalState.terminalIds.length <= 1;
467580 const fallbackExitWrite = () =>
......@@ -482,10 +595,10 @@ function PersistentThreadTerminalDrawer({
482595 void fallbackExitWrite();
483596 }
484597
485 storeCloseTerminal(threadId, terminalId);
598 storeCloseTerminal(threadRef, terminalId);
486599 bumpFocusRequestId();
487600 },
488 [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId],
601 [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef],
489602 );
490603
491604 const handleAddTerminalContext = useCallback(
......@@ -505,6 +618,7 @@ function PersistentThreadTerminalDrawer({
505618 return (
506619 <div className={visible ? undefined : "hidden"}>
507620 <ThreadTerminalDrawer
621 threadRef={threadRef}
508622 threadId={threadId}
509623 cwd={cwd}
510624 worktreePath={effectiveWorktreePath}
......@@ -530,12 +644,27 @@ function PersistentThreadTerminalDrawer({
530644 );
531645}
532646
533export default function ChatView({ threadId }: ChatViewProps) {
534 const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId]));
647export default function ChatView(props: ChatViewProps) {
648 const { environmentId, threadId, routeKind } = props;
649 const draftId = routeKind === "draft" ? props.draftId : null;
650 const routeThreadRef = useMemo(
651 () => scopeThreadRef(environmentId, threadId),
652 [environmentId, threadId],
653 );
654 const composerDraftTarget: ScopedThreadRef | DraftId =
655 routeKind === "server" ? routeThreadRef : props.draftId;
656 const serverThread = useStore(
657 useMemo(
658 () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null),
659 [routeKind, routeThreadRef],
660 ),
661 );
535662 const setStoreThreadError = useStore((store) => store.setError);
536663 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
537 const activeThreadLastVisitedAt = useUiStateStore(
538 (store) => store.threadLastVisitedAtById[threadId],
664 const activeThreadLastVisitedAt = useUiStateStore((store) =>
665 routeKind === "server"
666 ? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))]
667 : undefined,
539668 );
540669 const settings = useSettings();
541670 const setStickyComposerModelSelection = useComposerDraftStore(
......@@ -548,7 +677,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
548677 select: (params) => parseDiffRouteSearch(params),
549678 });
550679 const { resolvedTheme } = useTheme();
551 const composerDraft = useComposerThreadDraft(threadId);
680 const composerDraft = useComposerThreadDraft(composerDraftTarget);
552681 const prompt = composerDraft.prompt;
553682 const composerImages = composerDraft.images;
554683 const composerTerminalContexts = composerDraft.terminalContexts;
......@@ -591,16 +720,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
591720 );
592721 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
593722 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
594 const getDraftThreadByProjectId = useComposerDraftStore(
595 (store) => store.getDraftThreadByProjectId,
723 const getDraftSessionByLogicalProjectKey = useComposerDraftStore(
724 (store) => store.getDraftSessionByLogicalProjectKey,
596725 );
597 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
598 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
599 const clearProjectDraftThreadId = useComposerDraftStore(
600 (store) => store.clearProjectDraftThreadId,
726 const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
727 const getDraftSession = useComposerDraftStore((store) => store.getDraftSession);
728 const setLogicalProjectDraftThreadId = useComposerDraftStore(
729 (store) => store.setLogicalProjectDraftThreadId,
601730 );
602 const draftThread = useComposerDraftStore(
603 (store) => store.draftThreadsByThreadId[threadId] ?? null,
731 const draftThread = useComposerDraftStore((store) =>
732 routeKind === "server"
733 ? store.getDraftSessionByRef(routeThreadRef)
734 : draftId
735 ? store.getDraftSession(draftId)
736 : null,
604737 );
605738 const promptRef = useRef(prompt);
606739 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
......@@ -610,8 +743,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
610743 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
611744 optimisticUserMessagesRef.current = optimisticUserMessages;
612745 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts);
613 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
614 Record<ThreadId, string | null>
746 const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<
747 Record<string, string | null>
615748 >({});
616749 const [isConnecting, _setIsConnecting] = useState(false);
617750 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
......@@ -689,66 +822,73 @@ export default function ChatView({ threadId }: ChatViewProps) {
689822 setMessagesScrollElement(element);
690823 }, []);
691824
692 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
693 const terminalState = useMemo(
694 () => selectThreadTerminalState(terminalStateByThreadId, threadId),
695 [terminalStateByThreadId, threadId],
825 const terminalState = useTerminalStateStore((state) =>
826 selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef),
696827 );
697 const openTerminalThreadIds = useMemo(
698 () =>
699 Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) =>
700 nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [],
828 const openTerminalThreadKeys = useTerminalStateStore(
829 useShallow((state) =>
830 Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) =>
831 nextTerminalState.terminalOpen ? [nextThreadKey] : [],
701832 ),
702 [terminalStateByThreadId],
833 ),
703834 );
704835 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
705836 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
706837 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);
707838 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);
708839 const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);
709 const serverThreadIds = useStore((state) => state.threadIds);
840 const serverThreadKeys = useStore(
841 useShallow((state) =>
842 selectThreadsAcrossEnvironments(state).map((thread) =>
843 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
844 ),
845 ),
846 );
710847 const storeServerTerminalLaunchContext = useTerminalStateStore(
711 (s) => s.terminalLaunchContextByThreadId[threadId] ?? null,
848 (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null,
712849 );
713850 const storeClearTerminalLaunchContext = useTerminalStateStore(
714851 (s) => s.clearTerminalLaunchContext,
715852 );
716 const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId);
717 const draftThreadIds = useMemo(
718 () => Object.keys(draftThreadsByThreadId) as ThreadId[],
719 [draftThreadsByThreadId],
853 const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
854 const draftThreadKeys = useMemo(
855 () =>
856 Object.values(draftThreadsByThreadKey).map((draftThread) =>
857 scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)),
858 ),
859 [draftThreadsByThreadKey],
720860 );
721 const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState<ThreadId[]>([]);
861 const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState<string[]>([]);
722862
723863 const setPrompt = useCallback(
724864 (nextPrompt: string) => {
725 setComposerDraftPrompt(threadId, nextPrompt);
865 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
726866 },
727 [setComposerDraftPrompt, threadId],
867 [composerDraftTarget, setComposerDraftPrompt],
728868 );
729869 const addComposerImage = useCallback(
730870 (image: ComposerImageAttachment) => {
731 addComposerDraftImage(threadId, image);
871 addComposerDraftImage(composerDraftTarget, image);
732872 },
733 [addComposerDraftImage, threadId],
873 [addComposerDraftImage, composerDraftTarget],
734874 );
735875 const addComposerImagesToDraft = useCallback(
736876 (images: ComposerImageAttachment[]) => {
737 addComposerDraftImages(threadId, images);
877 addComposerDraftImages(composerDraftTarget, images);
738878 },
739 [addComposerDraftImages, threadId],
879 [addComposerDraftImages, composerDraftTarget],
740880 );
741881 const addComposerTerminalContextsToDraft = useCallback(
742882 (contexts: TerminalContextDraft[]) => {
743 addComposerDraftTerminalContexts(threadId, contexts);
883 addComposerDraftTerminalContexts(composerDraftTarget, contexts);
744884 },
745 [addComposerDraftTerminalContexts, threadId],
885 [addComposerDraftTerminalContexts, composerDraftTarget],
746886 );
747887 const removeComposerImageFromDraft = useCallback(
748888 (imageId: string) => {
749 removeComposerDraftImage(threadId, imageId);
889 removeComposerDraftImage(composerDraftTarget, imageId);
750890 },
751 [removeComposerDraftImage, threadId],
891 [composerDraftTarget, removeComposerDraftImage],
752892 );
753893 const removeComposerTerminalContextFromDraft = useCallback(
754894 (contextId: string) => {
......@@ -761,7 +901,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
761901 const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex);
762902 promptRef.current = nextPrompt.prompt;
763903 setPrompt(nextPrompt.prompt);
764 removeComposerDraftTerminalContext(threadId, contextId);
904 removeComposerDraftTerminalContext(composerDraftTarget, contextId);
765905 setComposerCursor(nextPrompt.cursor);
766906 setComposerTrigger(
767907 detectComposerTrigger(
......@@ -770,13 +910,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
770910 ),
771911 );
772912 },
773 [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId],
913 [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt],
774914 );
775915
776 const fallbackDraftProject = useStore((state) =>
777 draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined,
916 const fallbackDraftProjectRef = draftThread
917 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
918 : null;
919 const fallbackDraftProject = useStore(
920 useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]),
778921 );
779 const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
922 const localDraftError =
923 routeKind === "server" && serverThread
924 ? null
925 : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null);
780926 const localDraftThread = useMemo(
781927 () =>
782928 draftThread
......@@ -792,20 +938,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
792938 : undefined,
793939 [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId],
794940 );
795 const activeThread = serverThread ?? localDraftThread;
941 const isServerThread = routeKind === "server" && serverThread !== undefined;
942 const activeThread = isServerThread ? serverThread : localDraftThread;
796943 const runtimeMode =
797944 composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
798945 const interactionMode =
799946 composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
800 const isServerThread = serverThread !== undefined;
801947 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
802948 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
803949 const diffOpen = rawSearch.diff === "1";
804950 const activeThreadId = activeThread?.id ?? null;
805 const existingOpenTerminalThreadIds = useMemo(() => {
806 const existingThreadIds = new Set<ThreadId>([...serverThreadIds, ...draftThreadIds]);
807 return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId));
808 }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]);
951 const activeThreadRef = useMemo(
952 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
953 [activeThread],
954 );
955 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
956 const existingOpenTerminalThreadKeys = useMemo(() => {
957 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
958 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
959 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
809960 const activeLatestTurn = activeThread?.latestTurn ?? null;
810961 const threadPlanCatalog = useThreadPlanCatalog(
811962 useMemo(() => {
......@@ -825,12 +976,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
825976 [activeThread?.activities],
826977 );
827978 useEffect(() => {
828 setMountedTerminalThreadIds((currentThreadIds) => {
979 setMountedTerminalThreadKeys((currentThreadIds) => {
829980 const nextThreadIds = reconcileMountedTerminalThreadIds({
830981 currentThreadIds,
831 openThreadIds: existingOpenTerminalThreadIds,
832 activeThreadId,
833 activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen),
982 openThreadIds: existingOpenTerminalThreadKeys,
983 activeThreadId: activeThreadKey,
984 activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen),
834985 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
835986 });
836987 return currentThreadIds.length === nextThreadIds.length &&
......@@ -838,10 +989,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
838989 ? currentThreadIds
839990 : nextThreadIds;
840991 });
841 }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]);
992 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]);
842993 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
843 const activeProject = useStore((state) =>
844 activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined,
994 const activeProjectRef = activeThread
995 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
996 : null;
997 const activeProject = useStore(
998 useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]),
845999 );
8461000
8471001 const openPullRequestDialog = useCallback(
......@@ -867,50 +1021,71 @@ export default function ChatView({ threadId }: ChatViewProps) {
8671021 if (!activeProject) {
8681022 throw new Error("No active project is available for this pull request.");
8691023 }
870 const storedDraftThread = getDraftThreadByProjectId(activeProject.id);
871 if (storedDraftThread) {
872 setDraftThreadContext(storedDraftThread.threadId, input);
873 setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input);
874 if (storedDraftThread.threadId !== threadId) {
1024 const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id);
1025 const logicalProjectKey = deriveLogicalProjectKey(activeProject);
1026 const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey);
1027 if (storedDraftSession) {
1028 setDraftThreadContext(storedDraftSession.draftId, input);
1029 setLogicalProjectDraftThreadId(
1030 logicalProjectKey,
1031 activeProjectRef,
1032 storedDraftSession.draftId,
1033 {
1034 threadId: storedDraftSession.threadId,
1035 ...input,
1036 },
1037 );
1038 if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) {
8751039 await navigate({
876 to: "/$threadId",
877 params: { threadId: storedDraftThread.threadId },
1040 to: "/draft/$draftId",
1041 params: buildDraftThreadRouteParams(storedDraftSession.draftId),
8781042 });
8791043 }
880 return storedDraftThread.threadId;
1044 return storedDraftSession.threadId;
8811045 }
8821046
883 const activeDraftThread = getDraftThread(threadId);
884 if (!isServerThread && activeDraftThread?.projectId === activeProject.id) {
885 setDraftThreadContext(threadId, input);
886 setProjectDraftThreadId(activeProject.id, threadId, input);
887 return threadId;
1047 const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null;
1048 if (
1049 !isServerThread &&
1050 activeDraftSession?.logicalProjectKey === logicalProjectKey &&
1051 draftId
1052 ) {
1053 setDraftThreadContext(draftId, input);
1054 setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, {
1055 threadId: activeDraftSession.threadId,
1056 createdAt: activeDraftSession.createdAt,
1057 runtimeMode: activeDraftSession.runtimeMode,
1058 interactionMode: activeDraftSession.interactionMode,
1059 ...input,
1060 });
1061 return activeDraftSession.threadId;
8881062 }
8891063
890 clearProjectDraftThreadId(activeProject.id);
1064 const nextDraftId = newDraftId();
8911065 const nextThreadId = newThreadId();
892 setProjectDraftThreadId(activeProject.id, nextThreadId, {
1066 setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {
1067 threadId: nextThreadId,
8931068 createdAt: new Date().toISOString(),
8941069 runtimeMode: DEFAULT_RUNTIME_MODE,
8951070 interactionMode: DEFAULT_INTERACTION_MODE,
8961071 ...input,
8971072 });
8981073 await navigate({
899 to: "/$threadId",
900 params: { threadId: nextThreadId },
1074 to: "/draft/$draftId",
1075 params: buildDraftThreadRouteParams(nextDraftId),
9011076 });
9021077 return nextThreadId;
9031078 },
9041079 [
9051080 activeProject,
906 clearProjectDraftThreadId,
907 getDraftThread,
908 getDraftThreadByProjectId,
1081 draftId,
1082 getDraftSession,
1083 getDraftSessionByLogicalProjectKey,
9091084 isServerThread,
9101085 navigate,
1086 routeKind,
9111087 setDraftThreadContext,
912 setProjectDraftThreadId,
913 threadId,
1088 setLogicalProjectDraftThreadId,
9141089 ],
9151090 );
9161091
......@@ -934,12 +1109,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
9341109 const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
9351110 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
9361111
937 markThreadVisited(serverThread.id);
1112 markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)));
9381113 }, [
9391114 activeLatestTurn?.completedAt,
9401115 activeThreadLastVisitedAt,
9411116 latestTurnSettled,
9421117 markThreadVisited,
1118 serverThread?.environmentId,
9431119 serverThread?.id,
9441120 ]);
9451121
......@@ -959,7 +1135,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
9591135 );
9601136 const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider;
9611137 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
962 threadId,
1138 threadRef: composerDraftTarget,
9631139 providers: providerStatuses,
9641140 selectedProvider,
9651141 threadModelSelection: activeThread?.modelSelection,
......@@ -1358,7 +1534,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13581534 (debouncerState) => ({ isPending: debouncerState.isPending }),
13591535 );
13601536 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
1361 const gitStatusQuery = useGitStatus(gitCwd);
1537 const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd });
13621538 const keybindings = useServerKeybindings();
13631539 const availableEditors = useServerAvailableEditors();
13641540 const modelOptionsByProvider = useMemo(
......@@ -1394,6 +1570,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13941570 );
13951571 const workspaceEntriesQuery = useQuery(
13961572 projectSearchEntriesQueryOptions({
1573 environmentId,
13971574 cwd: gitCwd,
13981575 query: effectivePathQuery,
13991576 enabled: isPathTrigger,
......@@ -1531,16 +1708,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
15311708 [keybindings, nonTerminalShortcutLabelOptions],
15321709 );
15331710 const onToggleDiff = useCallback(() => {
1711 if (!isServerThread) {
1712 return;
1713 }
15341714 void navigate({
1535 to: "/$threadId",
1536 params: { threadId },
1715 to: "/$environmentId/$threadId",
1716 params: {
1717 environmentId,
1718 threadId,
1719 },
15371720 replace: true,
15381721 search: (previous) => {
15391722 const rest = stripDiffSearchParams(previous);
15401723 return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
15411724 },
15421725 });
1543 }, [diffOpen, navigate, threadId]);
1726 }, [diffOpen, environmentId, isServerThread, navigate, threadId]);
15441727
15451728 const envLocked = Boolean(
15461729 activeThread &&
......@@ -1561,21 +1744,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
15611744 (targetThreadId: ThreadId | null, error: string | null) => {
15621745 if (!targetThreadId) return;
15631746 const nextError = sanitizeThreadErrorMessage(error);
1564 if (useStore.getState().threadShellById[targetThreadId] !== undefined) {
1747 const isCurrentServerThread = shouldWriteThreadErrorToCurrentServerThread({
1748 serverThread,
1749 routeThreadRef,
1750 targetThreadId,
1751 });
1752 if (isCurrentServerThread) {
15651753 setStoreThreadError(targetThreadId, nextError);
15661754 return;
15671755 }
1568 setLocalDraftErrorsByThreadId((existing) => {
1569 if ((existing[targetThreadId] ?? null) === nextError) {
1756 const localDraftErrorKey = draftId ?? targetThreadId;
1757 setLocalDraftErrorsByDraftId((existing) => {
1758 if ((existing[localDraftErrorKey] ?? null) === nextError) {
15701759 return existing;
15711760 }
15721761 return {
15731762 ...existing,
1574 [targetThreadId]: nextError,
1763 [localDraftErrorKey]: nextError,
15751764 };
15761765 });
15771766 },
1578 [setStoreThreadError],
1767 [draftId, routeThreadRef, serverThread, setStoreThreadError],
15791768 );
15801769
15811770 const focusComposer = useCallback(() => {
......@@ -1606,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16061795 insertion.cursor,
16071796 );
16081797 const inserted = insertComposerDraftTerminalContext(
1609 activeThread.id,
1798 scopeThreadRef(activeThread.environmentId, activeThread.id),
16101799 insertion.prompt,
16111800 {
16121801 id: randomUUID(),
......@@ -1630,30 +1819,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
16301819 );
16311820 const setTerminalOpen = useCallback(
16321821 (open: boolean) => {
1633 if (!activeThreadId) return;
1634 storeSetTerminalOpen(activeThreadId, open);
1822 if (!activeThreadRef) return;
1823 storeSetTerminalOpen(activeThreadRef, open);
16351824 },
1636 [activeThreadId, storeSetTerminalOpen],
1825 [activeThreadRef, storeSetTerminalOpen],
16371826 );
16381827 const toggleTerminalVisibility = useCallback(() => {
1639 if (!activeThreadId) return;
1828 if (!activeThreadRef) return;
16401829 setTerminalOpen(!terminalState.terminalOpen);
1641 }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]);
1830 }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]);
16421831 const splitTerminal = useCallback(() => {
1643 if (!activeThreadId || hasReachedSplitLimit) return;
1832 if (!activeThreadRef || hasReachedSplitLimit) return;
16441833 const terminalId = `terminal-${randomUUID()}`;
1645 storeSplitTerminal(activeThreadId, terminalId);
1834 storeSplitTerminal(activeThreadRef, terminalId);
16461835 setTerminalFocusRequestId((value) => value + 1);
1647 }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]);
1836 }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]);
16481837 const createNewTerminal = useCallback(() => {
1649 if (!activeThreadId) return;
1838 if (!activeThreadRef) return;
16501839 const terminalId = `terminal-${randomUUID()}`;
1651 storeNewTerminal(activeThreadId, terminalId);
1840 storeNewTerminal(activeThreadRef, terminalId);
16521841 setTerminalFocusRequestId((value) => value + 1);
1653 }, [activeThreadId, storeNewTerminal]);
1842 }, [activeThreadRef, storeNewTerminal]);
16541843 const closeTerminal = useCallback(
16551844 (terminalId: string) => {
1656 const api = readNativeApi();
1845 const api = readEnvironmentApi(environmentId);
16571846 if (!activeThreadId || !api) return;
16581847 const isFinalTerminal = terminalState.terminalIds.length <= 1;
16591848 const fallbackExitWrite = () =>
......@@ -1676,10 +1865,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
16761865 } else {
16771866 void fallbackExitWrite();
16781867 }
1679 storeCloseTerminal(activeThreadId, terminalId);
1868 if (activeThreadRef) {
1869 storeCloseTerminal(activeThreadRef, terminalId);
1870 }
16801871 setTerminalFocusRequestId((value) => value + 1);
16811872 },
1682 [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length],
1873 [
1874 activeThreadId,
1875 activeThreadRef,
1876 environmentId,
1877 storeCloseTerminal,
1878 terminalState.terminalIds.length,
1879 ],
16831880 );
16841881 const runProjectScript = useCallback(
16851882 async (
......@@ -1692,7 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16921889 rememberAsLastInvoked?: boolean;
16931890 },
16941891 ) => {
1695 const api = readNativeApi();
1892 const api = readEnvironmentApi(environmentId);
16961893 if (!api || !activeThreadId || !activeProject || !activeThread) return;
16971894 if (options?.rememberAsLastInvoked !== false) {
16981895 setLastInvokedScriptByProjectId((current) => {
......@@ -1719,10 +1916,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
17191916 worktreePath: targetWorktreePath,
17201917 });
17211918 setTerminalOpen(true);
1919 if (!activeThreadRef) {
1920 return;
1921 }
17221922 if (shouldCreateNewTerminal) {
1723 storeNewTerminal(activeThreadId, targetTerminalId);
1923 storeNewTerminal(activeThreadRef, targetTerminalId);
17241924 } else {
1725 storeSetActiveTerminal(activeThreadId, targetTerminalId);
1925 storeSetActiveTerminal(activeThreadRef, targetTerminalId);
17261926 }
17271927 setTerminalFocusRequestId((value) => value + 1);
17281928
......@@ -1769,12 +1969,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
17691969 activeProject,
17701970 activeThread,
17711971 activeThreadId,
1972 activeThreadRef,
17721973 gitCwd,
17731974 setTerminalOpen,
17741975 setThreadError,
17751976 storeNewTerminal,
17761977 storeSetActiveTerminal,
17771978 setLastInvokedScriptByProjectId,
1979 environmentId,
17781980 terminalState.activeTerminalId,
17791981 terminalState.runningTerminalIds,
17801982 terminalState.terminalIds,
......@@ -1790,7 +1992,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17901992 keybinding?: string | null;
17911993 keybindingCommand: KeybindingCommand;
17921994 }) => {
1793 const api = readNativeApi();
1995 const api = readEnvironmentApi(environmentId);
17941996 if (!api) return;
17951997
17961998 await api.orchestration.dispatchCommand({
......@@ -1806,10 +2008,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
18062008 });
18072009
18082010 if (isElectron && keybindingRule) {
1809 await api.server.upsertKeybinding(keybindingRule);
2011 const localApi = readLocalApi();
2012 if (!localApi) {
2013 throw new Error("Local API unavailable.");
2014 }
2015 await localApi.server.upsertKeybinding(keybindingRule);
18102016 }
18112017 },
1812 [],
2018 [environmentId],
18132019 );
18142020 const saveProjectScript = useCallback(
18152021 async (input: NewProjectScriptInput) => {
......@@ -1913,9 +2119,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
19132119 const handleRuntimeModeChange = useCallback(
19142120 (mode: RuntimeMode) => {
19152121 if (mode === runtimeMode) return;
1916 setComposerDraftRuntimeMode(threadId, mode);
2122 setComposerDraftRuntimeMode(composerDraftTarget, mode);
19172123 if (isLocalDraftThread) {
1918 setDraftThreadContext(threadId, { runtimeMode: mode });
2124 setDraftThreadContext(composerDraftTarget, { runtimeMode: mode });
19192125 }
19202126 scheduleComposerFocus();
19212127 },
......@@ -1923,18 +2129,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
19232129 isLocalDraftThread,
19242130 runtimeMode,
19252131 scheduleComposerFocus,
2132 composerDraftTarget,
19262133 setComposerDraftRuntimeMode,
19272134 setDraftThreadContext,
1928 threadId,
19292135 ],
19302136 );
19312137
19322138 const handleInteractionModeChange = useCallback(
19332139 (mode: ProviderInteractionMode) => {
19342140 if (mode === interactionMode) return;
1935 setComposerDraftInteractionMode(threadId, mode);
2141 setComposerDraftInteractionMode(composerDraftTarget, mode);
19362142 if (isLocalDraftThread) {
1937 setDraftThreadContext(threadId, { interactionMode: mode });
2143 setDraftThreadContext(composerDraftTarget, { interactionMode: mode });
19382144 }
19392145 scheduleComposerFocus();
19402146 },
......@@ -1942,9 +2148,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
19422148 interactionMode,
19432149 isLocalDraftThread,
19442150 scheduleComposerFocus,
2151 composerDraftTarget,
19452152 setComposerDraftInteractionMode,
19462153 setDraftThreadContext,
1947 threadId,
19482154 ],
19492155 );
19502156 const toggleInteractionMode = useCallback(() => {
......@@ -1980,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19802186 if (!serverThread) {
19812187 return;
19822188 }
1983 const api = readNativeApi();
2189 const api = readEnvironmentApi(environmentId);
19842190 if (!api) {
19852191 return;
19862192 }
......@@ -2020,7 +2226,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
20202226 });
20212227 }
20222228 },
2023 [serverThread],
2229 [environmentId, serverThread],
20242230 );
20252231
20262232 // Auto-scroll on new messages
......@@ -2350,17 +2556,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
23502556 dragDepthRef.current = 0;
23512557 setIsDragOverComposer(false);
23522558 setExpandedImage(null);
2353 }, [resetLocalDispatch, threadId]);
2559 }, [draftId, resetLocalDispatch, threadId]);
23542560
23552561 useEffect(() => {
23562562 let cancelled = false;
23572563 void (async () => {
23582564 if (composerImages.length === 0) {
2359 clearComposerDraftPersistedAttachments(threadId);
2565 clearComposerDraftPersistedAttachments(composerDraftTarget);
23602566 return;
23612567 }
23622568 const getPersistedAttachmentsForThread = () =>
2363 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
2569 getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];
23642570 try {
23652571 const currentPersistedAttachments = getPersistedAttachmentsForThread();
23662572 const existingPersistedById = new Map(
......@@ -2391,7 +2597,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
23912597 return;
23922598 }
23932599 // Stage attachments in persisted draft state first so persist middleware can write them.
2394 syncComposerDraftPersistedAttachments(threadId, serialized);
2600 syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);
23952601 } catch {
23962602 const currentImageIds = new Set(composerImages.map((image) => image.id));
23972603 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
......@@ -2405,17 +2611,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
24052611 if (cancelled) {
24062612 return;
24072613 }
2408 syncComposerDraftPersistedAttachments(threadId, fallbackAttachments);
2614 syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);
24092615 }
24102616 })();
24112617 return () => {
24122618 cancelled = true;
24132619 };
24142620 }, [
2621 composerDraftTarget,
24152622 clearComposerDraftPersistedAttachments,
24162623 composerImages,
2624 getComposerDraft,
24172625 syncComposerDraftPersistedAttachments,
2418 threadId,
24192626 ]);
24202627
24212628 const closeExpandedImage = useCallback(() => {
......@@ -2476,7 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
24762683 useEffect(() => {
24772684 if (!activeThreadId) {
24782685 setTerminalLaunchContext(null);
2479 storeClearTerminalLaunchContext(threadId);
2686 storeClearTerminalLaunchContext(routeThreadRef);
24802687 return;
24812688 }
24822689 setTerminalLaunchContext((current) => {
......@@ -2484,7 +2691,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
24842691 if (current.threadId === activeThreadId) return current;
24852692 return null;
24862693 });
2487 }, [activeThreadId, storeClearTerminalLaunchContext, threadId]);
2694 }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]);
24882695
24892696 useEffect(() => {
24902697 if (!activeThreadId || !activeProjectCwd) {
......@@ -2502,12 +2709,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
25022709 settledCwd === current.cwd &&
25032710 (activeThreadWorktreePath ?? null) === current.worktreePath
25042711 ) {
2505 storeClearTerminalLaunchContext(activeThreadId);
2712 if (activeThreadRef) {
2713 storeClearTerminalLaunchContext(activeThreadRef);
2714 }
25062715 return null;
25072716 }
25082717 return current;
25092718 });
2510 }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]);
2719 }, [
2720 activeProjectCwd,
2721 activeThreadId,
2722 activeThreadRef,
2723 activeThreadWorktreePath,
2724 storeClearTerminalLaunchContext,
2725 ]);
25112726
25122727 useEffect(() => {
25132728 if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) {
......@@ -2521,11 +2736,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
25212736 settledCwd === storeServerTerminalLaunchContext.cwd &&
25222737 (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath
25232738 ) {
2524 storeClearTerminalLaunchContext(activeThreadId);
2739 if (activeThreadRef) {
2740 storeClearTerminalLaunchContext(activeThreadRef);
2741 }
25252742 }
25262743 }, [
25272744 activeProjectCwd,
25282745 activeThreadId,
2746 activeThreadRef,
25292747 activeThreadWorktreePath,
25302748 storeClearTerminalLaunchContext,
25312749 storeServerTerminalLaunchContext,
......@@ -2535,11 +2753,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
25352753 if (terminalState.terminalOpen) {
25362754 return;
25372755 }
2538 if (activeThreadId) {
2539 storeClearTerminalLaunchContext(activeThreadId);
2756 if (activeThreadRef) {
2757 storeClearTerminalLaunchContext(activeThreadRef);
25402758 }
25412759 setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current));
2542 }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]);
2760 }, [
2761 activeThreadId,
2762 activeThreadRef,
2763 storeClearTerminalLaunchContext,
2764 terminalState.terminalOpen,
2765 ]);
25432766
25442767 useEffect(() => {
25452768 if (phase !== "running") return;
......@@ -2552,16 +2775,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
25522775 }, [phase]);
25532776
25542777 useEffect(() => {
2555 if (!activeThreadId) return;
2556 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
2778 if (!activeThreadKey) return;
2779 const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false;
25572780 const current = Boolean(terminalState.terminalOpen);
25582781
25592782 if (!previous && current) {
2560 terminalOpenByThreadRef.current[activeThreadId] = current;
2783 terminalOpenByThreadRef.current[activeThreadKey] = current;
25612784 setTerminalFocusRequestId((value) => value + 1);
25622785 return;
25632786 } else if (previous && !current) {
2564 terminalOpenByThreadRef.current[activeThreadId] = current;
2787 terminalOpenByThreadRef.current[activeThreadKey] = current;
25652788 const frame = window.requestAnimationFrame(() => {
25662789 focusComposer();
25672790 });
......@@ -2570,8 +2793,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
25702793 };
25712794 }
25722795
2573 terminalOpenByThreadRef.current[activeThreadId] = current;
2574 }, [activeThreadId, focusComposer, terminalState.terminalOpen]);
2796 terminalOpenByThreadRef.current[activeThreadKey] = current;
2797 }, [activeThreadKey, focusComposer, terminalState.terminalOpen]);
25752798
25762799 useEffect(() => {
25772800 const handler = (event: globalThis.KeyboardEvent) => {
......@@ -2766,14 +2989,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
27662989
27672990 const onRevertToTurnCount = useCallback(
27682991 async (turnCount: number) => {
2769 const api = readNativeApi();
2770 if (!api || !activeThread || isRevertingCheckpoint) return;
2992 const api = readEnvironmentApi(environmentId);
2993 const localApi = readLocalApi();
2994 if (!api || !localApi || !activeThread || isRevertingCheckpoint) return;
27712995
27722996 if (phase === "running" || isSendBusy || isConnecting) {
27732997 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
27742998 return;
27752999 }
2776 const confirmed = await api.dialogs.confirm(
3000 const confirmed = await localApi.dialogs.confirm(
27773001 [
27783002 `Revert this thread to checkpoint ${turnCount}?`,
27793003 "This will discard newer messages and turn diffs in this thread.",
......@@ -2802,12 +3026,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
28023026 }
28033027 setIsRevertingCheckpoint(false);
28043028 },
2805 [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
3029 [
3030 activeThread,
3031 environmentId,
3032 isConnecting,
3033 isRevertingCheckpoint,
3034 isSendBusy,
3035 phase,
3036 setThreadError,
3037 ],
28063038 );
28073039
28083040 const onSend = async (e?: { preventDefault: () => void }) => {
28093041 e?.preventDefault();
2810 const api = readNativeApi();
3042 const api = readEnvironmentApi(environmentId);
28113043 if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return;
28123044 if (activePendingProgress) {
28133045 onAdvanceActivePendingUserInput();
......@@ -2830,7 +3062,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
28303062 planMarkdown: activeProposedPlan.planMarkdown,
28313063 });
28323064 promptRef.current = "";
2833 clearComposerDraftContent(activeThread.id);
3065 clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id));
28343066 setComposerHighlightedItemId(null);
28353067 setComposerCursor(0);
28363068 setComposerTrigger(null);
......@@ -2847,7 +3079,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
28473079 if (standaloneSlashCommand) {
28483080 handleInteractionModeChange(standaloneSlashCommand);
28493081 promptRef.current = "";
2850 clearComposerDraftContent(activeThread.id);
3082 clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id));
28513083 setComposerHighlightedItemId(null);
28523084 setComposerCursor(0);
28533085 setComposerTrigger(null);
......@@ -2880,10 +3112,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
28803112 const shouldCreateWorktree =
28813113 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
28823114 if (shouldCreateWorktree && !activeThread.branch) {
2883 setStoreThreadError(
2884 threadIdForSend,
2885 "Select a base branch before sending in New worktree mode.",
2886 );
3115 setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
28873116 return;
28883117 }
28893118
......@@ -2950,7 +3179,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29503179 });
29513180 }
29523181 promptRef.current = "";
2953 clearComposerDraftContent(threadIdForSend);
3182 clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend));
29543183 setComposerHighlightedItemId(null);
29553184 setComposerCursor(0);
29563185 setComposerTrigger(null);
......@@ -3087,7 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
30873316 };
30883317
30893318 const onInterrupt = async () => {
3090 const api = readNativeApi();
3319 const api = readEnvironmentApi(environmentId);
30913320 if (!api || !activeThread) return;
30923321 await api.orchestration.dispatchCommand({
30933322 type: "thread.turn.interrupt",
......@@ -3099,7 +3328,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
30993328
31003329 const onRespondToApproval = useCallback(
31013330 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
3102 const api = readNativeApi();
3331 const api = readEnvironmentApi(environmentId);
31033332 if (!api || !activeThreadId) return;
31043333
31053334 setRespondingRequestIds((existing) =>
......@@ -3122,12 +3351,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
31223351 });
31233352 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
31243353 },
3125 [activeThreadId, setThreadError],
3354 [activeThreadId, environmentId, setThreadError],
31263355 );
31273356
31283357 const onRespondToUserInput = useCallback(
31293358 async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => {
3130 const api = readNativeApi();
3359 const api = readEnvironmentApi(environmentId);
31313360 if (!api || !activeThreadId) return;
31323361
31333362 setRespondingUserInputRequestIds((existing) =>
......@@ -3150,7 +3379,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
31503379 });
31513380 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
31523381 },
3153 [activeThreadId, setThreadError],
3382 [activeThreadId, environmentId, setThreadError],
31543383 );
31553384
31563385 const setActivePendingUserInputQuestionIndex = useCallback(
......@@ -3166,31 +3395,38 @@ export default function ChatView({ threadId }: ChatViewProps) {
31663395 [activePendingUserInput],
31673396 );
31683397
3169 const onToggleActivePendingUserInputOption = useCallback(
3398 const onSelectActivePendingUserInputOption = useCallback(
31703399 (questionId: string, optionLabel: string) => {
31713400 if (!activePendingUserInput) {
31723401 return;
31733402 }
3174 const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
3175 if (!question) {
3176 return;
3177 }
3178 setPendingUserInputAnswersByRequestId((existing) => ({
3179 ...existing,
3180 [activePendingUserInput.requestId]: {
3181 ...existing[activePendingUserInput.requestId],
3182 [questionId]: togglePendingUserInputOptionSelection(
3183 question,
3184 existing[activePendingUserInput.requestId]?.[questionId],
3185 optionLabel,
3186 ),
3187 },
3188 }));
3403 setPendingUserInputAnswersByRequestId((existing) => {
3404 const question =
3405 (activePendingProgress?.activeQuestion?.id === questionId
3406 ? activePendingProgress.activeQuestion
3407 : undefined) ??
3408 activePendingUserInput.questions.find((entry) => entry.id === questionId);
3409 if (!question) {
3410 return existing;
3411 }
3412
3413 return {
3414 ...existing,
3415 [activePendingUserInput.requestId]: {
3416 ...existing[activePendingUserInput.requestId],
3417 [questionId]: togglePendingUserInputOptionSelection(
3418 question,
3419 existing[activePendingUserInput.requestId]?.[questionId],
3420 optionLabel,
3421 ),
3422 },
3423 };
3424 });
31893425 promptRef.current = "";
31903426 setComposerCursor(0);
31913427 setComposerTrigger(null);
31923428 },
3193 [activePendingUserInput],
3429 [activePendingProgress?.activeQuestion, activePendingUserInput],
31943430 );
31953431
31963432 const onChangeActivePendingUserInputCustomAnswer = useCallback(
......@@ -3257,7 +3493,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
32573493 text: string;
32583494 interactionMode: "default" | "plan";
32593495 }) => {
3260 const api = readNativeApi();
3496 const api = readEnvironmentApi(environmentId);
32613497 if (
32623498 !api ||
32633499 !activeThread ||
......@@ -3312,7 +3548,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
33123548
33133549 // Keep the mode toggle and plan-follow-up banner in sync immediately
33143550 // while the same-thread implementation turn is starting.
3315 setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode);
3551 setComposerDraftInteractionMode(
3552 scopeThreadRef(activeThread.environmentId, threadIdForSend),
3553 nextInteractionMode,
3554 );
33163555
33173556 await api.orchestration.dispatchCommand({
33183557 type: "thread.turn.start",
......@@ -3376,11 +3615,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
33763615 setComposerDraftInteractionMode,
33773616 setThreadError,
33783617 selectedModel,
3618 environmentId,
33793619 ],
33803620 );
33813621
33823622 const onImplementPlanInNewThread = useCallback(async () => {
3383 const api = readNativeApi();
3623 const api = readEnvironmentApi(environmentId);
33843624 if (
33853625 !api ||
33863626 !activeThread ||
......@@ -3452,17 +3692,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
34523692 });
34533693 })
34543694 .then(() => {
3455 return waitForStartedServerThread(nextThreadId);
3695 return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId));
34563696 })
34573697 .then(() => {
34583698 // Signal that the plan sidebar should open on the new thread.
34593699 planSidebarOpenOnNextThreadRef.current = true;
34603700 return navigate({
3461 to: "/$threadId",
3462 params: { threadId: nextThreadId },
3701 to: "/$environmentId/$threadId",
3702 params: {
3703 environmentId: activeThread.environmentId,
3704 threadId: nextThreadId,
3705 },
34633706 });
34643707 })
3465 .catch(async (err) => {
3708 .catch(async (err: unknown) => {
34663709 await api.orchestration
34673710 .dispatchCommand({
34683711 type: "thread.delete",
......@@ -3494,6 +3737,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
34943737 selectedProvider,
34953738 selectedProviderModels,
34963739 selectedModel,
3740 environmentId,
34973741 ]);
34983742
34993743 const onProviderModelSelect = useCallback(
......@@ -3514,7 +3758,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
35143758 provider: resolvedProvider,
35153759 model: resolvedModel,
35163760 };
3517 setComposerDraftModelSelection(activeThread.id, nextModelSelection);
3761 setComposerDraftModelSelection(
3762 scopeThreadRef(activeThread.environmentId, activeThread.id),
3763 nextModelSelection,
3764 );
35183765 setStickyComposerModelSelection(nextModelSelection);
35193766 scheduleComposerFocus();
35203767 },
......@@ -3546,7 +3793,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
35463793 );
35473794 const providerTraitsMenuContent = renderProviderTraitsMenuContent({
35483795 provider: selectedProvider,
3549 threadId,
3796 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
3797 ...(routeKind === "draft" && draftId ? { draftId } : {}),
35503798 model: selectedModel,
35513799 models: selectedProviderModels,
35523800 modelOptions: composerModelOptions?.[selectedProvider],
......@@ -3555,7 +3803,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
35553803 });
35563804 const providerTraitsPicker = renderProviderTraitsPicker({
35573805 provider: selectedProvider,
3558 threadId,
3806 ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}),
3807 ...(routeKind === "draft" && draftId ? { draftId } : {}),
35593808 model: selectedModel,
35603809 models: selectedProviderModels,
35613810 modelOptions: composerModelOptions?.[selectedProvider],
......@@ -3565,11 +3814,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
35653814 const onEnvModeChange = useCallback(
35663815 (mode: DraftThreadEnvMode) => {
35673816 if (isLocalDraftThread) {
3568 setDraftThreadContext(threadId, { envMode: mode });
3817 setDraftThreadContext(composerDraftTarget, { envMode: mode });
35693818 }
35703819 scheduleComposerFocus();
35713820 },
3572 [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId],
3821 [composerDraftTarget, isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext],
35733822 );
35743823
35753824 const applyPromptReplacement = useCallback(
......@@ -3766,7 +4015,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
37664015 setPrompt(nextPrompt);
37674016 if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) {
37684017 setComposerDraftTerminalContexts(
3769 threadId,
4018 composerDraftTarget,
37704019 syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds),
37714020 );
37724021 }
......@@ -3781,8 +4030,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
37814030 composerTerminalContexts,
37824031 onChangeActivePendingUserInputCustomAnswer,
37834032 setPrompt,
4033 composerDraftTarget,
37844034 setComposerDraftTerminalContexts,
3785 threadId,
37864035 ],
37874036 );
37884037
......@@ -3835,9 +4084,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
38354084 const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null;
38364085 const onOpenTurnDiff = useCallback(
38374086 (turnId: TurnId, filePath?: string) => {
4087 if (!isServerThread) {
4088 return;
4089 }
38384090 void navigate({
3839 to: "/$threadId",
3840 params: { threadId },
4091 to: "/$environmentId/$threadId",
4092 params: {
4093 environmentId,
4094 threadId,
4095 },
38414096 search: (previous) => {
38424097 const rest = stripDiffSearchParams(previous);
38434098 return filePath
......@@ -3846,7 +4101,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
38464101 },
38474102 });
38484103 },
3849 [navigate, threadId],
4104 [environmentId, isServerThread, navigate, threadId],
38504105 );
38514106 const onRevertUserMessage = (messageId: MessageId) => {
38524107 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
......@@ -3892,6 +4147,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
38924147 )}
38934148 >
38944149 <ChatHeader
4150 activeThreadEnvironmentId={activeThread.environmentId}
38954151 activeThreadId={activeThread.id}
38964152 activeThreadTitle={activeThread.title}
38974153 activeProjectName={activeProject?.name}
......@@ -3959,6 +4215,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
39594215 completionSummary={completionSummary}
39604216 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
39614217 nowIso={nowIso}
4218 activeThreadEnvironmentId={activeThread.environmentId}
39624219 expandedWorkGroups={expandedWorkGroups}
39634220 onToggleWorkGroup={onToggleWorkGroup}
39644221 onOpenTurnDiff={onOpenTurnDiff}
......@@ -4027,7 +4284,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
40274284 respondingRequestIds={respondingRequestIds}
40284285 answers={activePendingDraftAnswers}
40294286 questionIndex={activePendingQuestionIndex}
4030 onToggleOption={onToggleActivePendingUserInputOption}
4287 onToggleOption={onSelectActivePendingUserInputOption}
40314288 onAdvance={onAdvanceActivePendingUserInput}
40324289 />
40334290 </div>
......@@ -4370,7 +4627,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
43704627
43714628 {isGitRepo && (
43724629 <BranchToolbar
4630 environmentId={activeThread.environmentId}
43734631 threadId={activeThread.id}
4632 {...(routeKind === "draft" && draftId ? { draftId } : {})}
43744633 onEnvModeChange={onEnvModeChange}
43754634 envLocked={envLocked}
43764635 onComposerFocusRequest={scheduleComposerFocus}
......@@ -4383,6 +4642,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
43834642 <PullRequestThreadDialog
43844643 key={pullRequestDialogState.key}
43854644 open
4645 environmentId={activeThread.environmentId}
43864646 threadId={activeThread.id}
43874647 cwd={activeProject?.cwd ?? null}
43884648 initialReference={pullRequestDialogState.initialReference}
......@@ -4402,6 +4662,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
44024662 <PlanSidebar
44034663 activePlan={activePlan}
44044664 activeProposedPlan={sidebarProposedPlan}
4665 environmentId={environmentId}
44054666 markdownCwd={gitCwd ?? undefined}
44064667 workspaceRoot={activeWorkspaceRoot}
44074668 timestampFormat={timestampFormat}
......@@ -4418,21 +4679,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
44184679 </div>
44194680 {/* end horizontal flex container */}
44204681
4421 {mountedTerminalThreadIds.map((mountedThreadId) => (
4422 <PersistentThreadTerminalDrawer
4423 key={mountedThreadId}
4424 threadId={mountedThreadId}
4425 visible={mountedThreadId === activeThreadId && terminalState.terminalOpen}
4426 launchContext={
4427 mountedThreadId === activeThreadId ? (activeTerminalLaunchContext ?? null) : null
4428 }
4429 focusRequestId={mountedThreadId === activeThreadId ? terminalFocusRequestId : 0}
4430 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4431 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4432 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4433 onAddTerminalContext={addTerminalContextToDraft}
4434 />
4435 ))}
4682 {mountedTerminalThreadKeys.flatMap((mountedThreadKey) => {
4683 const mountedThreadRef = parseScopedThreadKey(mountedThreadKey);
4684 if (!mountedThreadRef) {
4685 return [];
4686 }
4687 return [
4688 <PersistentThreadTerminalDrawer
4689 key={mountedThreadKey}
4690 threadRef={mountedThreadRef}
4691 threadId={mountedThreadRef.threadId}
4692 visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen}
4693 launchContext={
4694 mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
4695 }
4696 focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0}
4697 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4698 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4699 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4700 onAddTerminalContext={addTerminalContextToDraft}
4701 />,
4702 ];
4703 })}
44364704
44374705 {expandedImage && expandedImageItem && (
44384706 <div
apps/web/src/components/DiffPanel.tsx+26-15
......@@ -2,7 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs";
22import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
33import { useQuery } from "@tanstack/react-query";
44import { 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";
67import {
78 ChevronLeftIcon,
89 ChevronRightIcon,
......@@ -22,15 +23,16 @@ import { openInPreferredEditor } from "../editorPreferences";
2223import { useGitStatus } from "~/lib/gitStatusState";
2324import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
2425import { cn } from "~/lib/utils";
25import { readNativeApi } from "../nativeApi";
26import { readLocalApi } from "../localApi";
2627import { resolvePathLinkTarget } from "../terminal-links";
2728import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
2829import { useTheme } from "../hooks/useTheme";
2930import { buildPatchCacheKey } from "../lib/diffRendering";
3031import { resolveDiffThemeName } from "../lib/diffRendering";
3132import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
32import { useStore } from "../store";
33import { createThreadSelector } from "../storeSelectors";
33import { selectProjectByRef, useStore } from "../store";
34import { createThreadSelectorByRef } from "../storeSelectors";
35import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
3436import { useSettings } from "../hooks/useSettings";
3537import { formatShortTimestamp } from "../timestampFormat";
3638import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
......@@ -175,22 +177,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
175177 const previousDiffOpenRef = useRef(false);
176178 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
177179 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
178 const routeThreadId = useParams({
180 const routeThreadRef = useParams({
179181 strict: false,
180 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
182 select: (params) => resolveThreadRouteRef(params),
181183 });
182184 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
183185 const diffOpen = diffSearch.diff === "1";
184 const activeThreadId = routeThreadId;
186 const activeThreadId = routeThreadRef?.threadId ?? null;
185187 const activeThread = useStore(
186 useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]),
188 useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]),
187189 );
188190 const activeProjectId = activeThread?.projectId ?? null;
189191 const activeProject = useStore((store) =>
190 activeProjectId ? store.projectById[activeProjectId] : undefined,
192 activeThread && activeProjectId
193 ? selectProjectByRef(store, {
194 environmentId: activeThread.environmentId,
195 projectId: activeProjectId,
196 })
197 : undefined,
191198 );
192199 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
193 const gitStatusQuery = useGitStatus(activeCwd ?? null);
200 const gitStatusQuery = useGitStatus({
201 environmentId: activeThread?.environmentId ?? null,
202 cwd: activeCwd ?? null,
203 });
194204 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
195205 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
196206 useTurnDiffSummaries(activeThread);
......@@ -263,6 +273,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
263273 }, [orderedTurnDiffSummaries, selectedTurn]);
264274 const activeCheckpointDiffQuery = useQuery(
265275 checkpointDiffQueryOptions({
276 environmentId: activeThread?.environmentId ?? null,
266277 threadId: activeThreadId,
267278 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
268279 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
......@@ -322,7 +333,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
322333
323334 const openDiffFileInEditor = useCallback(
324335 (filePath: string) => {
325 const api = readNativeApi();
336 const api = readLocalApi();
326337 if (!api) return;
327338 const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath;
328339 void openInPreferredEditor(api, targetPath).catch((error) => {
......@@ -335,8 +346,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
335346 const selectTurn = (turnId: TurnId) => {
336347 if (!activeThread) return;
337348 void navigate({
338 to: "/$threadId",
339 params: { threadId: activeThread.id },
349 to: "/$environmentId/$threadId",
350 params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)),
340351 search: (previous) => {
341352 const rest = stripDiffSearchParams(previous);
342353 return { ...rest, diff: "1", diffTurnId: turnId };
......@@ -346,8 +357,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
346357 const selectWholeConversation = () => {
347358 if (!activeThread) return;
348359 void navigate({
349 to: "/$threadId",
350 params: { threadId: activeThread.id },
360 to: "/$environmentId/$threadId",
361 params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)),
351362 search: (previous) => {
352363 const rest = stripDiffSearchParams(previous);
353364 return { ...rest, diff: "1" };
apps/web/src/components/GitActionsControl.browser.tsx+73-47
......@@ -1,3 +1,4 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";
12import { ThreadId } from "@t3tools/contracts";
23import { useState } from "react";
34import { afterEach, describe, expect, it, vi } from "vitest";
......@@ -5,10 +6,24 @@ import { render } from "vitest-browser-react";
56
67const THREAD_A = ThreadId.makeUnsafe("thread-a");
78const THREAD_B = ThreadId.makeUnsafe("thread-b");
9const ENVIRONMENT_ID = "environment-local" as never;
810const GIT_CWD = "/repo/project";
911const 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
1125const {
26 activeRunStackedActionDeferredRef,
1227 invalidateGitQueriesSpy,
1328 refreshGitStatusSpy,
1429 runStackedActionMutateAsyncSpy,
......@@ -18,9 +33,10 @@ const {
1833 toastPromiseSpy,
1934 toastUpdateSpy,
2035} = vi.hoisted(() => ({
36 activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },
2137 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),
2238 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),
23 runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)),
39 runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),
2440 setThreadBranchSpy: vi.fn(),
2541 toastAddSpy: vi.fn(() => "toast-1"),
2642 toastCloseSpy: vi.fn(),
......@@ -28,12 +44,8 @@ const {
2844 toastUpdateSpy: vi.fn(),
2945}));
3046
31vi.mock("@tanstack/react-query", async () => {
32 const actual =
33 await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
34
47vi.mock("@tanstack/react-query", () => {
3548 return {
36 ...actual,
3749 useIsMutating: vi.fn(() => 0),
3850 useMutation: vi.fn((options: { __kind?: string }) => {
3951 if (options.__kind === "run-stacked-action") {
......@@ -56,27 +68,7 @@ vi.mock("@tanstack/react-query", async () => {
5668 isPending: false,
5769 };
5870 }),
59 useQuery: vi.fn((options: { queryKey?: string[] }) => {
60 if (options.queryKey?.[0] === "git-branches") {
61 return {
62 data: {
63 isRepo: true,
64 hasOriginRemote: true,
65 branches: [
66 {
67 name: BRANCH_NAME,
68 current: true,
69 isDefault: false,
70 worktreePath: null,
71 },
72 ],
73 },
74 error: null,
75 };
76 }
77
78 return { data: null, error: null };
79 }),
71 useQuery: vi.fn(() => ({ data: null, error: null })),
8072 useQueryClient: vi.fn(() => ({})),
8173 };
8274});
......@@ -123,27 +115,44 @@ vi.mock("~/lib/gitStatusState", () => ({
123115 })),
124116}));
125117
126vi.mock("~/lib/utils", async () => {
127 const actual = await vi.importActual<typeof import("~/lib/utils")>("~/lib/utils");
128
129 return {
130 ...actual,
131 newCommandId: vi.fn(() => "command-1"),
132 randomUUID: vi.fn(() => "action-1"),
133 };
134});
135
136vi.mock("~/nativeApi", () => ({
137 readNativeApi: vi.fn(() => null),
118vi.mock("~/localApi", () => ({
119 readLocalApi: vi.fn(() => null),
138120}));
139121
140122vi.mock("~/store", () => ({
123 selectEnvironmentState: (
124 state: { environmentStateById: Record<string, unknown> },
125 environmentId: string | null,
126 ) => {
127 if (!environmentId) {
128 throw new Error("Missing environment id");
129 }
130 const environmentState = state.environmentStateById[environmentId];
131 if (!environmentState) {
132 throw new Error(`Unknown environment: ${environmentId}`);
133 }
134 return environmentState;
135 },
141136 useStore: (selector: (state: unknown) => unknown) =>
142137 selector({
143138 setThreadBranch: setThreadBranchSpy,
144 threadShellById: {
145 [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null },
146 [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null },
139 environmentStateById: {
140 [ENVIRONMENT_ID]: {
141 threadShellById: {
142 [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null },
143 [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null },
144 },
145 threadSessionById: {},
146 threadTurnStateById: {},
147 messageIdsByThreadId: {},
148 messageByThreadId: {},
149 activityIdsByThreadId: {},
150 activityByThreadId: {},
151 proposedPlanIdsByThreadId: {},
152 proposedPlanByThreadId: {},
153 turnDiffIdsByThreadId: {},
154 turnDiffSummaryByThreadId: {},
155 },
147156 },
148157 }),
149158}));
......@@ -168,7 +177,10 @@ function Harness() {
168177 <button type="button" onClick={() => setActiveThreadId(THREAD_B)}>
169178 Switch thread
170179 </button>
171 <GitActionsControl gitCwd={GIT_CWD} activeThreadId={activeThreadId} />
180 <GitActionsControl
181 gitCwd={GIT_CWD}
182 activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, activeThreadId)}
183 />
172184 </>
173185 );
174186}
......@@ -177,6 +189,7 @@ describe("GitActionsControl thread-scoped progress toast", () => {
177189 afterEach(() => {
178190 vi.useRealTimers();
179191 vi.clearAllMocks();
192 activeRunStackedActionDeferredRef.current = createDeferredPromise<never>();
180193 document.body.innerHTML = "";
181194 });
182195
......@@ -231,6 +244,9 @@ describe("GitActionsControl thread-scoped progress toast", () => {
231244 }),
232245 );
233246 } finally {
247 activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup"));
248 await Promise.resolve();
249 vi.useRealTimers();
234250 await screen.unmount();
235251 host.remove();
236252 }
......@@ -248,9 +264,15 @@ describe("GitActionsControl thread-scoped progress toast", () => {
248264
249265 const host = document.createElement("div");
250266 document.body.append(host);
251 const screen = await render(<GitActionsControl gitCwd={GIT_CWD} activeThreadId={THREAD_A} />, {
252 container: host,
253 });
267 const screen = await render(
268 <GitActionsControl
269 gitCwd={GIT_CWD}
270 activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, THREAD_A)}
271 />,
272 {
273 container: host,
274 },
275 );
254276
255277 try {
256278 window.dispatchEvent(new Event("focus"));
......@@ -264,11 +286,15 @@ describe("GitActionsControl thread-scoped progress toast", () => {
264286
265287 await vi.advanceTimersByTimeAsync(1);
266288 expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1);
267 expect(refreshGitStatusSpy).toHaveBeenCalledWith(GIT_CWD);
289 expect(refreshGitStatusSpy).toHaveBeenCalledWith({
290 environmentId: ENVIRONMENT_ID,
291 cwd: GIT_CWD,
292 });
268293 } finally {
269294 if (originalVisibilityState) {
270295 Object.defineProperty(document, "visibilityState", originalVisibilityState);
271296 }
297 vi.useRealTimers();
272298 await screen.unmount();
273299 host.remove();
274300 }
apps/web/src/components/GitActionsControl.tsx+42-20
......@@ -1,9 +1,9 @@
1import { type ScopedThreadRef } from "@t3tools/contracts";
12import type {
23 GitActionProgressEvent,
34 GitRunStackedActionResult,
45 GitStackedAction,
56 GitStatusResult,
6 ThreadId,
77} from "@t3tools/contracts";
88import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
99import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
......@@ -49,12 +49,14 @@ import {
4949import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
5050import { newCommandId, randomUUID } from "~/lib/utils";
5151import { resolvePathLinkTarget } from "~/terminal-links";
52import { readNativeApi } from "~/nativeApi";
52import { readEnvironmentApi } from "~/environmentApi";
53import { readLocalApi } from "~/localApi";
5354import { useStore } from "~/store";
55import { createThreadSelectorByRef } from "~/storeSelectors";
5456
5557interface GitActionsControlProps {
5658 gitCwd: string | null;
57 activeThreadId: ThreadId | null;
59 activeThreadRef: ScopedThreadRef | null;
5860}
5961
6062interface PendingDefaultBranchAction {
......@@ -206,14 +208,18 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
206208 return <InfoIcon className={iconClassName} />;
207209}
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;
210214 const threadToastData = useMemo(
211215 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
212216 [activeThreadId],
213217 );
214 const activeServerThread = useStore((store) =>
215 activeThreadId ? store.threadShellById[activeThreadId] : undefined,
218 const activeServerThreadSelector = useMemo(
219 () => createThreadSelectorByRef(activeThreadRef),
220 [activeThreadRef],
216221 );
222 const activeServerThread = useStore(activeServerThreadSelector);
217223 const setThreadBranch = useStore((store) => store.setThreadBranch);
218224 const queryClient = useQueryClient();
219225 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
......@@ -246,7 +252,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
246252 }
247253
248254 const worktreePath = activeServerThread.worktreePath;
249 const api = readNativeApi();
255 const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined;
250256 if (api) {
251257 void api.orchestration
252258 .dispatchCommand({
......@@ -261,7 +267,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
261267
262268 setThreadBranch(activeThreadId, branch, worktreePath);
263269 },
264 [activeServerThread, activeThreadId, setThreadBranch],
270 [activeEnvironmentId, activeServerThread, activeThreadId, setThreadBranch],
265271 );
266272
267273 const syncThreadBranchAfterGitAction = useCallback(
......@@ -276,7 +282,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
276282 [persistThreadBranchSync],
277283 );
278284
279 const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd);
285 const { data: gitStatus = null, error: gitStatusError } = useGitStatus({
286 environmentId: activeEnvironmentId,
287 cwd: gitCwd,
288 });
280289 // Default to true while loading so we don't flash init controls.
281290 const isRepo = gitStatus?.isRepo ?? true;
282291 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
......@@ -287,19 +296,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
287296 const allSelected = excludedFiles.size === 0;
288297 const noneSelected = selectedFiles.length === 0;
289298
290 const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));
299 const initMutation = useMutation(
300 gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),
301 );
291302
292303 const runImmediateGitActionMutation = useMutation(
293304 gitRunStackedActionMutationOptions({
305 environmentId: activeEnvironmentId,
294306 cwd: gitCwd,
295307 queryClient,
296308 }),
297309 );
298 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
310 const pullMutation = useMutation(
311 gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),
312 );
299313
300314 const isRunStackedActionRunning =
301 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;
302 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
315 useIsMutating({
316 mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd),
317 }) > 0;
318 const isPullRunning =
319 useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0;
303320 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;
304321
305322 useEffect(() => {
......@@ -372,7 +389,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
372389 }
373390 refreshTimeout = window.setTimeout(() => {
374391 refreshTimeout = null;
375 void refreshGitStatus(gitCwd).catch(() => undefined);
392 void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch(
393 () => undefined,
394 );
376395 }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS);
377396 };
378397 const handleVisibilityChange = () => {
......@@ -391,10 +410,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
391410 window.removeEventListener("focus", scheduleRefreshCurrentGitStatus);
392411 document.removeEventListener("visibilitychange", handleVisibilityChange);
393412 };
394 }, [gitCwd]);
413 }, [activeEnvironmentId, gitCwd]);
395414
396415 const openExistingPr = useCallback(async () => {
397 const api = readNativeApi();
416 const api = readLocalApi();
398417 if (!api) {
399418 toastManager.add({
400419 type: "error",
......@@ -412,7 +431,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
412431 });
413432 return;
414433 }
415 void api.shell.openExternal(prUrl).catch((err) => {
434 void api.shell.openExternal(prUrl).catch((err: unknown) => {
416435 toastManager.add({
417436 type: "error",
418437 title: "Unable to open PR link",
......@@ -601,7 +620,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
601620 toastActionProps = {
602621 children: toastCta.label,
603622 onClick: () => {
604 const api = readNativeApi();
623 const api = readLocalApi();
605624 if (!api) return;
606625 closeResultToast();
607626 void api.shell.openExternal(toastCta.url);
......@@ -760,7 +779,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
760779
761780 const openChangedFileInEditor = useCallback(
762781 (filePath: string) => {
763 const api = readNativeApi();
782 const api = readLocalApi();
764783 if (!api || !gitCwd) {
765784 toastManager.add({
766785 type: "error",
......@@ -836,7 +855,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
836855 <Menu
837856 onOpenChange={(open) => {
838857 if (open) {
839 void refreshGitStatus(gitCwd).catch(() => undefined);
858 void refreshGitStatus({
859 environmentId: activeEnvironmentId,
860 cwd: gitCwd,
861 }).catch(() => undefined);
840862 }
841863 }}
842864 >
apps/web/src/components/KeybindingsToast.browser.tsx+26-23
......@@ -2,6 +2,7 @@ import "../index.css";
22
33import {
44 DEFAULT_SERVER_SETTINGS,
5 EnvironmentId,
56 ORCHESTRATION_WS_METHODS,
67 type MessageId,
78 type OrchestrationReadModel,
......@@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
1819import { render } from "vitest-browser-react";
1920
2021import { useComposerDraftStore } from "../composerDraftStore";
21import { __resetNativeApiForTests } from "../nativeApi";
22import { __resetLocalApiForTests } from "../localApi";
2223import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
2324import { getServerConfig } from "../rpc/serverState";
2425import { getRouter } from "../router";
......@@ -34,6 +35,7 @@ vi.mock("../lib/gitStatusState", () => ({
3435
3536const THREAD_ID = "thread-kb-toast-test" as ThreadId;
3637const PROJECT_ID = "project-1" as ProjectId;
38const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
3739const NOW_ISO = "2026-03-04T12:00:00.000Z";
3840
3941interface TestFixture {
......@@ -49,6 +51,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/);
4951
5052function createBaseServerConfig(): ServerConfig {
5153 return {
54 environment: {
55 environmentId: LOCAL_ENVIRONMENT_ID,
56 label: "Local environment",
57 platform: { os: "darwin" as const, arch: "arm64" as const },
58 serverVersion: "0.0.0-test",
59 capabilities: { repositoryIdentity: true },
60 },
5261 cwd: "/repo/project",
5362 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
5463 keybindings: [],
......@@ -155,6 +164,13 @@ function buildFixture(): TestFixture {
155164 snapshot: createMinimalSnapshot(),
156165 serverConfig: createBaseServerConfig(),
157166 welcome: {
167 environment: {
168 environmentId: LOCAL_ENVIRONMENT_ID,
169 label: "Local environment",
170 platform: { os: "darwin" as const, arch: "arm64" as const },
171 serverVersion: "0.0.0-test",
172 capabilities: { repositoryIdentity: true },
173 },
158174 cwd: "/repo/project",
159175 projectName: "Project",
160176 bootstrapProjectId: PROJECT_ID,
......@@ -286,7 +302,9 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {
286302 host.style.overflow = "hidden";
287303 document.body.append(host);
288304
289 const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] }));
305 const router = getRouter(
306 createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }),
307 );
290308
291309 const screen = await render(
292310 <AppAtomRegistryProvider>
......@@ -347,32 +365,17 @@ describe("Keybindings update toast", () => {
347365 return [];
348366 },
349367 });
350 await __resetNativeApiForTests();
368 await __resetLocalApiForTests();
351369 localStorage.clear();
352370 document.body.innerHTML = "";
353371 useComposerDraftStore.setState({
354 draftsByThreadId: {},
355 draftThreadsByThreadId: {},
356 projectDraftThreadIdByProjectId: {},
372 draftsByThreadKey: {},
373 draftThreadsByThreadKey: {},
374 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
357375 });
358376 useStore.setState({
359 projectIds: [],
360 projectById: {},
361 threadIds: [],
362 threadIdsByProjectId: {},
363 threadShellById: {},
364 threadSessionById: {},
365 threadTurnStateById: {},
366 messageIdsByThreadId: {},
367 messageByThreadId: {},
368 activityIdsByThreadId: {},
369 activityByThreadId: {},
370 proposedPlanIdsByThreadId: {},
371 proposedPlanByThreadId: {},
372 turnDiffIdsByThreadId: {},
373 turnDiffSummaryByThreadId: {},
374 sidebarThreadSummaryById: {},
375 bootstrapComplete: false,
377 activeEnvironmentId: null,
378 environmentStateById: {},
376379 });
377380 });
378381
apps/web/src/components/PlanSidebar.tsx+6-3
......@@ -1,4 +1,5 @@
11import { memo, useState, useCallback } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";
23import { type TimestampFormat } from "@t3tools/contracts/settings";
34import { Badge } from "./ui/badge";
45import { Button } from "./ui/button";
......@@ -24,7 +25,7 @@ import {
2425 stripDisplayedPlanMarkdown,
2526} from "../proposedPlan";
2627import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
27import { readNativeApi } from "~/nativeApi";
28import { readEnvironmentApi } from "~/environmentApi";
2829import { toastManager } from "./ui/toast";
2930import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
3031
......@@ -53,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode {
5354interface PlanSidebarProps {
5455 activePlan: ActivePlanState | null;
5556 activeProposedPlan: LatestProposedPlanState | null;
57 environmentId: EnvironmentId;
5658 markdownCwd: string | undefined;
5759 workspaceRoot: string | undefined;
5860 timestampFormat: TimestampFormat;
......@@ -62,6 +64,7 @@ interface PlanSidebarProps {
6264const PlanSidebar = memo(function PlanSidebar({
6365 activePlan,
6466 activeProposedPlan,
67 environmentId,
6568 markdownCwd,
6669 workspaceRoot,
6770 timestampFormat,
......@@ -87,7 +90,7 @@ const PlanSidebar = memo(function PlanSidebar({
8790 }, [planMarkdown]);
8891
8992 const handleSaveToWorkspace = useCallback(() => {
90 const api = readNativeApi();
93 const api = readEnvironmentApi(environmentId);
9194 if (!api || !workspaceRoot || !planMarkdown) return;
9295 const filename = buildProposedPlanMarkdownFilename(planMarkdown);
9396 setIsSavingToWorkspace(true);
......@@ -115,7 +118,7 @@ const PlanSidebar = memo(function PlanSidebar({
115118 () => setIsSavingToWorkspace(false),
116119 () => setIsSavingToWorkspace(false),
117120 );
118 }, [planMarkdown, workspaceRoot]);
121 }, [environmentId, planMarkdown, workspaceRoot]);
119122
120123 return (
121124 <div className="flex h-full w-[340px] shrink-0 flex-col border-l border-border/70 bg-card/50">
apps/web/src/components/PullRequestThreadDialog.tsx+7-3
......@@ -1,4 +1,4 @@
1import type { GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts";
1import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
33import { useDebouncedValue } from "@tanstack/react-pacer";
44import { useCallback, useEffect, useMemo, useRef, useState } from "react";
......@@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner";
2424
2525interface PullRequestThreadDialogProps {
2626 open: boolean;
27 environmentId: EnvironmentId;
2728 threadId: ThreadId;
2829 cwd: string | null;
2930 initialReference: string | null;
......@@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps {
3334
3435export function PullRequestThreadDialog({
3536 open,
37 environmentId,
3638 threadId,
3739 cwd,
3840 initialReference,
......@@ -72,6 +74,7 @@ export function PullRequestThreadDialog({
7274 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);
7375 const resolvePullRequestQuery = useQuery(
7476 gitResolvePullRequestQueryOptions({
77 environmentId,
7578 cwd,
7679 reference: open ? parsedDebouncedReference : null,
7780 }),
......@@ -83,13 +86,14 @@ export function PullRequestThreadDialog({
8386 const cached = queryClient.getQueryData<GitResolvePullRequestResult>([
8487 "git",
8588 "pull-request",
89 environmentId,
8690 cwd,
8791 parsedReference,
8892 ]);
8993 return cached?.pullRequest ?? null;
90 }, [cwd, parsedReference, queryClient]);
94 }, [cwd, environmentId, parsedReference, queryClient]);
9195 const preparePullRequestThreadMutation = useMutation(
92 gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }),
96 gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }),
9397 );
9498
9599 const liveResolvedPullRequest =
apps/web/src/components/Sidebar.logic.test.ts+5-1
......@@ -20,7 +20,7 @@ import {
2020 sortThreadsForSidebar,
2121 THREAD_JUMP_HINT_SHOW_DELAY_MS,
2222} from "./Sidebar.logic";
23import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts";
23import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts";
2424import {
2525 DEFAULT_INTERACTION_MODE,
2626 DEFAULT_RUNTIME_MODE,
......@@ -28,6 +28,8 @@ import {
2828 type Thread,
2929} from "../types";
3030
31const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
32
3133function makeLatestTurn(overrides?: {
3234 completedAt?: string | null;
3335 startedAt?: string | null;
......@@ -625,6 +627,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
625627 const { defaultModelSelection, ...rest } = overrides;
626628 return {
627629 id: ProjectId.makeUnsafe("project-1"),
630 environmentId: localEnvironmentId,
628631 name: "Project",
629632 cwd: "/tmp/project",
630633 defaultModelSelection: {
......@@ -642,6 +645,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
642645function makeThread(overrides: Partial<Thread> = {}): Thread {
643646 return {
644647 id: ThreadId.makeUnsafe("thread-1"),
648 environmentId: localEnvironmentId,
645649 codexThreadId: null,
646650 projectId: ProjectId.makeUnsafe("project-1"),
647651 title: "Thread",
apps/web/src/components/Sidebar.tsx+2083-1402
......@@ -12,20 +12,7 @@ import {
1212} from "lucide-react";
1313import { ProjectFavicon } from "./ProjectFavicon";
1414import { autoAnimate } from "@formkit/auto-animate";
15import {
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";
15import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
2916import { useShallow } from "zustand/react/shallow";
3017import {
3118 DndContext,
......@@ -45,11 +32,20 @@ import { CSS } from "@dnd-kit/utilities";
4532import {
4633 DEFAULT_MODEL_BY_PROVIDER,
4734 type DesktopUpdateState,
35 type EnvironmentId,
4836 ProjectId,
37 type ScopedThreadRef,
38 type ThreadEnvMode,
4939 ThreadId,
5040 type GitStatusResult,
5141} from "@t3tools/contracts";
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";
5349import {
5450 type SidebarProjectSortOrder,
5551 type SidebarThreadSortOrder,
......@@ -58,7 +54,13 @@ import { isElectron } from "../env";
5854import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
5955import { isTerminalFocused } from "../lib/terminalFocus";
6056import { 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";
6264import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
6365import { useUiStateStore } from "../uiStateStore";
6466import {
......@@ -70,11 +72,16 @@ import {
7072 threadTraversalDirectionFromCommand,
7173} from "../keybindings";
7274import { useGitStatus } from "../lib/gitStatusState";
73import { readNativeApi } from "../nativeApi";
75import { readLocalApi } from "../localApi";
7476import { useComposerDraftStore } from "../composerDraftStore";
7577import { useHandleNewThread } from "../hooks/useHandleNewThread";
7678
7779import { useThreadActions } from "../hooks/useThreadActions";
80import {
81 buildThreadRouteParams,
82 resolveThreadRouteRef,
83 resolveThreadRouteTarget,
84} from "../threadRoutes";
7885import { toastManager } from "./ui/toast";
7986import { formatRelativeTimeLabel } from "../timestampFormat";
8087import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
......@@ -109,8 +116,6 @@ import {
109116import { useThreadSelectionStore } from "../threadSelectionStore";
110117import { isNonEmpty as isNonEmptyString } from "effect/String";
111118import {
112 getVisibleSidebarThreadIds,
113 getVisibleThreadsForProject,
114119 resolveAdjacentThreadId,
115120 isContextMenuPointerDown,
116121 resolveProjectStatusIndicator,
......@@ -123,12 +128,14 @@ import {
123128 sortProjectsForSidebar,
124129 sortThreadsForSidebar,
125130 useThreadJumpHintVisibility,
131 ThreadStatusPill,
126132} from "./Sidebar.logic";
127133import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
128134import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
135import { readEnvironmentApi } from "../environmentApi";
129136import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
130137import { useServerKeybindings } from "../rpc/serverState";
131import type { Project } from "../types";
138import type { Project, SidebarThreadSummary } from "../types";
132139const THREAD_PREVIEW_LIMIT = 6;
133140const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
134141 updated_at: "Last user message",
......@@ -143,9 +150,58 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
143150 duration: 180,
144151 easing: "ease-out",
145152} 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
147203type SidebarProjectSnapshot = Project & {
148 expanded: boolean;
204 projectKey: string;
149205};
150206interface TerminalStatusIndicator {
151207 label: "Terminal process running";
......@@ -166,7 +222,7 @@ function ThreadStatusLabel({
166222 status,
167223 compact = false,
168224}: {
169 status: NonNullable<ReturnType<typeof resolveThreadStatusPill>>;
225 status: ThreadStatusPill;
170226 compact?: boolean;
171227}) {
172228 if (compact) {
......@@ -255,57 +311,81 @@ function resolveThreadPr(
255311}
256312
257313interface SidebarThreadRowProps {
258 threadId: ThreadId;
314 thread: SidebarThreadSummary;
259315 projectCwd: string | null;
260 orderedProjectThreadIds: readonly ThreadId[];
261 routeThreadId: ThreadId | null;
262 selectedThreadIds: ReadonlySet<ThreadId>;
263 showThreadJumpHints: boolean;
316 orderedProjectThreadKeys: readonly string[];
317 isActive: boolean;
264318 jumpLabel: string | null;
265319 appSettingsConfirmThreadArchive: boolean;
266 renamingThreadId: ThreadId | null;
320 renamingThreadKey: string | null;
267321 renamingTitle: string;
268322 setRenamingTitle: (title: string) => void;
269 onRenamingInputMount: (element: HTMLInputElement | null) => void;
270 hasRenameCommitted: () => boolean;
271 markRenameCommitted: () => void;
272 confirmingArchiveThreadId: ThreadId | null;
273 setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>;
274 confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>;
323 renamingInputRef: React.RefObject<HTMLInputElement | null>;
324 renamingCommittedRef: React.RefObject<boolean>;
325 confirmingArchiveThreadKey: string | null;
326 setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>;
327 confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>;
275328 handleThreadClick: (
276 event: MouseEvent,
277 threadId: ThreadId,
278 orderedProjectThreadIds: readonly ThreadId[],
329 event: React.MouseEvent,
330 threadRef: ScopedThreadRef,
331 orderedProjectThreadKeys: readonly string[],
279332 ) => void;
280 navigateToThread: (threadId: ThreadId) => void;
333 navigateToThread: (threadRef: ScopedThreadRef) => void;
281334 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
282335 handleThreadContextMenu: (
283 threadId: ThreadId,
336 threadRef: ScopedThreadRef,
284337 position: { x: number; y: number },
285338 ) => Promise<void>;
286339 clearSelection: () => void;
287 commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise<void>;
340 commitRename: (
341 threadRef: ScopedThreadRef,
342 newTitle: string,
343 originalTitle: string,
344 ) => Promise<void>;
288345 cancelRename: () => void;
289 attemptArchiveThread: (threadId: ThreadId) => Promise<void>;
290 openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void;
346 attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>;
347 openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;
291348}
292349
293function SidebarThreadRow(props: SidebarThreadRowProps) {
294 const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]);
295 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]);
350const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
351 const {
352 orderedProjectThreadKeys,
353 isActive,
354 jumpLabel,
355 appSettingsConfirmThreadArchive,
356 renamingThreadKey,
357 renamingTitle,
358 setRenamingTitle,
359 renamingInputRef,
360 renamingCommittedRef,
361 confirmingArchiveThreadKey,
362 setConfirmingArchiveThreadKey,
363 confirmArchiveButtonRefs,
364 handleThreadClick,
365 navigateToThread,
366 handleMultiSelectContextMenu,
367 handleThreadContextMenu,
368 clearSelection,
369 commitRename,
370 cancelRename,
371 attemptArchiveThread,
372 openPrLink,
373 thread,
374 } = props;
375 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
376 const threadKey = scopedThreadKey(threadRef);
377 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
378 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
379 const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0);
296380 const runningTerminalIds = useTerminalStateStore(
297381 (state) =>
298 selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds,
382 selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds,
299383 );
300 const gitCwd = thread?.worktreePath ?? props.projectCwd;
301 const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null);
302
303 if (!thread) {
304 return null;
305 }
306
307 const isActive = props.routeThreadId === thread.id;
308 const isSelected = props.selectedThreadIds.has(thread.id);
384 const gitCwd = thread.worktreePath ?? props.projectCwd;
385 const gitStatus = useGitStatus({
386 environmentId: thread.environmentId,
387 cwd: thread.branch != null ? gitCwd : null,
388 });
309389 const isHighlighted = isActive || isSelected;
310390 const isThreadRunning =
311391 thread.session?.status === "running" && thread.session.activeTurnId != null;
......@@ -318,32 +398,173 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
318398 const pr = resolveThreadPr(thread.branch, gitStatus.data);
319399 const prStatus = prStatusIndicator(pr);
320400 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
321 const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning;
401 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
322402 const threadMetaClassName = isConfirmingArchive
323403 ? "pointer-events-none opacity-0"
324404 : !isThreadRunning
325405 ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"
326406 : "pointer-events-none";
407 const clearConfirmingArchive = useCallback(() => {
408 setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current));
409 }, [setConfirmingArchiveThreadKey, threadKey]);
410 const handleMouseLeave = useCallback(() => {
411 clearConfirmingArchive();
412 }, [clearConfirmingArchive]);
413 const handleBlurCapture = useCallback(
414 (event: React.FocusEvent<HTMLLIElement>) => {
415 const currentTarget = event.currentTarget;
416 requestAnimationFrame(() => {
417 if (currentTarget.contains(document.activeElement)) {
418 return;
419 }
420 clearConfirmingArchive();
421 });
422 },
423 [clearConfirmingArchive],
424 );
425 const handleRowClick = useCallback(
426 (event: React.MouseEvent) => {
427 handleThreadClick(event, threadRef, orderedProjectThreadKeys);
428 },
429 [handleThreadClick, orderedProjectThreadKeys, threadRef],
430 );
431 const handleRowKeyDown = useCallback(
432 (event: React.KeyboardEvent) => {
433 if (event.key !== "Enter" && event.key !== " ") return;
434 event.preventDefault();
435 navigateToThread(threadRef);
436 },
437 [navigateToThread, threadRef],
438 );
439 const handleRowContextMenu = useCallback(
440 (event: React.MouseEvent) => {
441 event.preventDefault();
442 if (hasSelection && isSelected) {
443 void handleMultiSelectContextMenu({
444 x: event.clientX,
445 y: event.clientY,
446 });
447 return;
448 }
449
450 if (hasSelection) {
451 clearSelection();
452 }
453 void handleThreadContextMenu(threadRef, {
454 x: event.clientX,
455 y: event.clientY,
456 });
457 },
458 [
459 clearSelection,
460 handleMultiSelectContextMenu,
461 handleThreadContextMenu,
462 hasSelection,
463 isSelected,
464 threadRef,
465 ],
466 );
467 const handlePrClick = useCallback(
468 (event: React.MouseEvent<HTMLButtonElement>) => {
469 if (!prStatus) return;
470 openPrLink(event, prStatus.url);
471 },
472 [openPrLink, prStatus],
473 );
474 const handleRenameInputRef = useCallback(
475 (element: HTMLInputElement | null) => {
476 if (element && renamingInputRef.current !== element) {
477 renamingInputRef.current = element;
478 element.focus();
479 element.select();
480 }
481 },
482 [renamingInputRef],
483 );
484 const handleRenameInputChange = useCallback(
485 (event: React.ChangeEvent<HTMLInputElement>) => {
486 setRenamingTitle(event.target.value);
487 },
488 [setRenamingTitle],
489 );
490 const handleRenameInputKeyDown = useCallback(
491 (event: React.KeyboardEvent<HTMLInputElement>) => {
492 event.stopPropagation();
493 if (event.key === "Enter") {
494 event.preventDefault();
495 renamingCommittedRef.current = true;
496 void commitRename(threadRef, renamingTitle, thread.title);
497 } else if (event.key === "Escape") {
498 event.preventDefault();
499 renamingCommittedRef.current = true;
500 cancelRename();
501 }
502 },
503 [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef],
504 );
505 const handleRenameInputBlur = useCallback(() => {
506 if (!renamingCommittedRef.current) {
507 void commitRename(threadRef, renamingTitle, thread.title);
508 }
509 }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]);
510 const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => {
511 event.stopPropagation();
512 }, []);
513 const handleConfirmArchiveRef = useCallback(
514 (element: HTMLButtonElement | null) => {
515 if (element) {
516 confirmArchiveButtonRefs.current.set(threadKey, element);
517 } else {
518 confirmArchiveButtonRefs.current.delete(threadKey);
519 }
520 },
521 [confirmArchiveButtonRefs, threadKey],
522 );
523 const stopPropagationOnPointerDown = useCallback(
524 (event: React.PointerEvent<HTMLButtonElement>) => {
525 event.stopPropagation();
526 },
527 [],
528 );
529 const handleConfirmArchiveClick = useCallback(
530 (event: React.MouseEvent<HTMLButtonElement>) => {
531 event.preventDefault();
532 event.stopPropagation();
533 clearConfirmingArchive();
534 void attemptArchiveThread(threadRef);
535 },
536 [attemptArchiveThread, clearConfirmingArchive, threadRef],
537 );
538 const handleStartArchiveConfirmation = useCallback(
539 (event: React.MouseEvent<HTMLButtonElement>) => {
540 event.preventDefault();
541 event.stopPropagation();
542 setConfirmingArchiveThreadKey(threadKey);
543 requestAnimationFrame(() => {
544 confirmArchiveButtonRefs.current.get(threadKey)?.focus();
545 });
546 },
547 [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey],
548 );
549 const handleArchiveImmediateClick = useCallback(
550 (event: React.MouseEvent<HTMLButtonElement>) => {
551 event.preventDefault();
552 event.stopPropagation();
553 void attemptArchiveThread(threadRef);
554 },
555 [attemptArchiveThread, threadRef],
556 );
557 const rowButtonRender = useMemo(() => <div role="button" tabIndex={0} />, []);
327558
328559 return (
329560 <SidebarMenuSubItem
330561 className="w-full"
331562 data-thread-item
332 onMouseLeave={() => {
333 props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
334 }}
335 onBlurCapture={(event) => {
336 const currentTarget = event.currentTarget;
337 requestAnimationFrame(() => {
338 if (currentTarget.contains(document.activeElement)) {
339 return;
340 }
341 props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
342 });
343 }}
563 onMouseLeave={handleMouseLeave}
564 onBlurCapture={handleBlurCapture}
344565 >
345566 <SidebarMenuSubButton
346 render={<div role="button" tabIndex={0} />}
567 render={rowButtonRender}
347568 size="sm"
348569 isActive={isActive}
349570 data-testid={`thread-row-${thread.id}`}
......@@ -351,31 +572,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
351572 isActive,
352573 isSelected,
353574 })} relative isolate`}
354 onClick={(event) => {
355 props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds);
356 }}
357 onKeyDown={(event) => {
358 if (event.key !== "Enter" && event.key !== " ") return;
359 event.preventDefault();
360 props.navigateToThread(thread.id);
361 }}
362 onContextMenu={(event) => {
363 event.preventDefault();
364 if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) {
365 void props.handleMultiSelectContextMenu({
366 x: event.clientX,
367 y: event.clientY,
368 });
369 } else {
370 if (props.selectedThreadIds.size > 0) {
371 props.clearSelection();
372 }
373 void props.handleThreadContextMenu(thread.id, {
374 x: event.clientX,
375 y: event.clientY,
376 });
377 }
378 }}
575 onClick={handleRowClick}
576 onKeyDown={handleRowKeyDown}
577 onContextMenu={handleRowContextMenu}
379578 >
380579 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
381580 {prStatus && (
......@@ -386,9 +585,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
386585 type="button"
387586 aria-label={prStatus.tooltip}
388587 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
389 onClick={(event) => {
390 props.openPrLink(event, prStatus.url);
391 }}
588 onClick={handlePrClick}
392589 >
393590 <GitPullRequestIcon className="size-3" />
394591 </button>
......@@ -398,30 +595,15 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
398595 </Tooltip>
399596 )}
400597 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
401 {props.renamingThreadId === thread.id ? (
598 {renamingThreadKey === threadKey ? (
402599 <input
403 ref={props.onRenamingInputMount}
600 ref={handleRenameInputRef}
404601 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
405 value={props.renamingTitle}
406 onChange={(event) => props.setRenamingTitle(event.target.value)}
407 onKeyDown={(event) => {
408 event.stopPropagation();
409 if (event.key === "Enter") {
410 event.preventDefault();
411 props.markRenameCommitted();
412 void props.commitRename(thread.id, props.renamingTitle, thread.title);
413 } else if (event.key === "Escape") {
414 event.preventDefault();
415 props.markRenameCommitted();
416 props.cancelRename();
417 }
418 }}
419 onBlur={() => {
420 if (!props.hasRenameCommitted()) {
421 void props.commitRename(thread.id, props.renamingTitle, thread.title);
422 }
423 }}
424 onClick={(event) => event.stopPropagation()}
602 value={renamingTitle}
603 onChange={handleRenameInputChange}
604 onKeyDown={handleRenameInputKeyDown}
605 onBlur={handleRenameInputBlur}
606 onClick={handleRenameInputClick}
425607 />
426608 ) : (
427609 <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
......@@ -441,34 +623,19 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
441623 <div className="flex min-w-12 justify-end">
442624 {isConfirmingArchive ? (
443625 <button
444 ref={(element) => {
445 if (element) {
446 props.confirmArchiveButtonRefs.current.set(thread.id, element);
447 } else {
448 props.confirmArchiveButtonRefs.current.delete(thread.id);
449 }
450 }}
626 ref={handleConfirmArchiveRef}
451627 type="button"
452628 data-thread-selection-safe
453629 data-testid={`thread-archive-confirm-${thread.id}`}
454630 aria-label={`Confirm archive ${thread.title}`}
455631 className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40"
456 onPointerDown={(event) => {
457 event.stopPropagation();
458 }}
459 onClick={(event) => {
460 event.preventDefault();
461 event.stopPropagation();
462 props.setConfirmingArchiveThreadId((current) =>
463 current === thread.id ? null : current,
464 );
465 void props.attemptArchiveThread(thread.id);
466 }}
632 onPointerDown={stopPropagationOnPointerDown}
633 onClick={handleConfirmArchiveClick}
467634 >
468635 Confirm
469636 </button>
470637 ) : !isThreadRunning ? (
471 props.appSettingsConfirmThreadArchive ? (
638 appSettingsConfirmThreadArchive ? (
472639 <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
473640 <button
474641 type="button"
......@@ -476,17 +643,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
476643 data-testid={`thread-archive-${thread.id}`}
477644 aria-label={`Archive ${thread.title}`}
478645 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
479 onPointerDown={(event) => {
480 event.stopPropagation();
481 }}
482 onClick={(event) => {
483 event.preventDefault();
484 event.stopPropagation();
485 props.setConfirmingArchiveThreadId(thread.id);
486 requestAnimationFrame(() => {
487 props.confirmArchiveButtonRefs.current.get(thread.id)?.focus();
488 });
489 }}
646 onPointerDown={stopPropagationOnPointerDown}
647 onClick={handleStartArchiveConfirmation}
490648 >
491649 <ArchiveIcon className="size-3.5" />
492650 </button>
......@@ -502,14 +660,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
502660 data-testid={`thread-archive-${thread.id}`}
503661 aria-label={`Archive ${thread.title}`}
504662 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
505 onPointerDown={(event) => {
506 event.stopPropagation();
507 }}
508 onClick={(event) => {
509 event.preventDefault();
510 event.stopPropagation();
511 void props.attemptArchiveThread(thread.id);
512 }}
663 onPointerDown={stopPropagationOnPointerDown}
664 onClick={handleArchiveImmediateClick}
513665 >
514666 <ArchiveIcon className="size-3.5" />
515667 </button>
......@@ -521,12 +673,12 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
521673 )
522674 ) : null}
523675 <span className={threadMetaClassName}>
524 {props.showThreadJumpHints && props.jumpLabel ? (
676 {jumpLabel ? (
525677 <span
526678 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
527 title={props.jumpLabel}
679 title={jumpLabel}
528680 >
529 {props.jumpLabel}
681 {jumpLabel}
530682 </span>
531683 ) : (
532684 <span
......@@ -545,248 +697,288 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
545697 </SidebarMenuSubButton>
546698 </SidebarMenuSubItem>
547699 );
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;
548747}
549748
550function T3Wordmark() {
551 return (
552 <svg
553 aria-label="T3"
554 className="h-2.5 w-auto shrink-0 text-foreground"
555 viewBox="15.5309 37 94.3941 56.96"
556 xmlns="http://www.w3.org/2000/svg"
557 >
558 <path
559 d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
560 fill="currentColor"
561 />
562 </svg>
563 );
564}
565
566type SortableProjectHandleProps = Pick<
567 ReturnType<typeof useSortable>,
568 "attributes" | "listeners" | "setActivatorNodeRef"
569>;
749const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
750 props: SidebarProjectThreadListProps,
751) {
752 const {
753 projectKey,
754 projectExpanded,
755 hasOverflowingThreads,
756 hiddenThreadStatus,
757 orderedProjectThreadKeys,
758 renderedThreads,
759 showEmptyThreadState,
760 shouldShowThreadPanel,
761 isThreadListExpanded,
762 projectCwd,
763 activeRouteThreadKey,
764 threadJumpLabelByKey,
765 appSettingsConfirmThreadArchive,
766 renamingThreadKey,
767 renamingTitle,
768 setRenamingTitle,
769 renamingInputRef,
770 renamingCommittedRef,
771 confirmingArchiveThreadKey,
772 setConfirmingArchiveThreadKey,
773 confirmArchiveButtonRefs,
774 attachThreadListAutoAnimateRef,
775 handleThreadClick,
776 navigateToThread,
777 handleMultiSelectContextMenu,
778 handleThreadContextMenu,
779 clearSelection,
780 commitRename,
781 cancelRename,
782 attemptArchiveThread,
783 openPrLink,
784 expandThreadListForProject,
785 collapseThreadListForProject,
786 } = props;
787 const showMoreButtonRender = useMemo(() => <button type="button" />, []);
788 const showLessButtonRender = useMemo(() => <button type="button" />, []);
570789
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}) {
582790 return (
583 <Menu>
584 <Tooltip>
585 <TooltipTrigger
586 render={
587 <MenuTrigger className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" />
588 }
589 >
590 <ArrowUpDownIcon className="size-3.5" />
591 </TooltipTrigger>
592 <TooltipPopup side="right">Sort projects</TooltipPopup>
593 </Tooltip>
594 <MenuPopup align="end" side="bottom" className="min-w-44">
595 <MenuGroup>
596 <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground">
597 Sort projects
791 <SidebarMenuSub
792 ref={attachThreadListAutoAnimateRef}
793 className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0"
794 >
795 {shouldShowThreadPanel && showEmptyThreadState ? (
796 <SidebarMenuSubItem className="w-full" data-thread-selection-safe>
797 <div
798 data-thread-selection-safe
799 className="flex h-6 w-full translate-x-0 items-center px-2 text-left text-[10px] text-muted-foreground/60"
800 >
801 <span>No threads yet</span>
598802 </div>
599 <MenuRadioGroup
600 value={projectSortOrder}
601 onValueChange={(value) => {
602 onProjectSortOrderChange(value as SidebarProjectSortOrder);
803 </SidebarMenuSubItem>
804 ) : null}
805 {shouldShowThreadPanel &&
806 renderedThreads.map((thread) => {
807 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
808 return (
809 <SidebarThreadRow
810 key={threadKey}
811 thread={thread}
812 projectCwd={projectCwd}
813 orderedProjectThreadKeys={orderedProjectThreadKeys}
814 isActive={activeRouteThreadKey === threadKey}
815 jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null}
816 appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
817 renamingThreadKey={renamingThreadKey}
818 renamingTitle={renamingTitle}
819 setRenamingTitle={setRenamingTitle}
820 renamingInputRef={renamingInputRef}
821 renamingCommittedRef={renamingCommittedRef}
822 confirmingArchiveThreadKey={confirmingArchiveThreadKey}
823 setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
824 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
825 handleThreadClick={handleThreadClick}
826 navigateToThread={navigateToThread}
827 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
828 handleThreadContextMenu={handleThreadContextMenu}
829 clearSelection={clearSelection}
830 commitRename={commitRename}
831 cancelRename={cancelRename}
832 attemptArchiveThread={attemptArchiveThread}
833 openPrLink={openPrLink}
834 />
835 );
836 })}
837
838 {projectExpanded && hasOverflowingThreads && !isThreadListExpanded && (
839 <SidebarMenuSubItem className="w-full">
840 <SidebarMenuSubButton
841 render={showMoreButtonRender}
842 data-thread-selection-safe
843 size="sm"
844 className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
845 onClick={() => {
846 expandThreadListForProject(projectKey);
603847 }}
604848 >
605 {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map(
606 ([value, label]) => (
607 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
608 {label}
609 </MenuRadioItem>
610 ),
611 )}
612 </MenuRadioGroup>
613 </MenuGroup>
614 <MenuGroup>
615 <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground">
616 Sort threads
617 </div>
618 <MenuRadioGroup
619 value={threadSortOrder}
620 onValueChange={(value) => {
621 onThreadSortOrderChange(value as SidebarThreadSortOrder);
849 <span className="flex min-w-0 flex-1 items-center gap-2">
850 {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />}
851 <span>Show more</span>
852 </span>
853 </SidebarMenuSubButton>
854 </SidebarMenuSubItem>
855 )}
856 {projectExpanded && hasOverflowingThreads && isThreadListExpanded && (
857 <SidebarMenuSubItem className="w-full">
858 <SidebarMenuSubButton
859 render={showLessButtonRender}
860 data-thread-selection-safe
861 size="sm"
862 className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
863 onClick={() => {
864 collapseThreadListForProject(projectKey);
622865 }}
623866 >
624 {(
625 Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]>
626 ).map(([value, label]) => (
627 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
628 {label}
629 </MenuRadioItem>
630 ))}
631 </MenuRadioGroup>
632 </MenuGroup>
633 </MenuPopup>
634 </Menu>
867 <span>Show less</span>
868 </SidebarMenuSubButton>
869 </SidebarMenuSubItem>
870 )}
871 </SidebarMenuSub>
635872 );
873});
874
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;
636892}
637893
638function SortableProjectItem({
639 projectId,
640 disabled = false,
641 children,
642}: {
643 projectId: ProjectId;
644 disabled?: boolean;
645 children: (handleProps: SortableProjectHandleProps) => ReactNode;
646}) {
894const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) {
647895 const {
648 attributes,
649 listeners,
650 setActivatorNodeRef,
651 setNodeRef,
652 transform,
653 transition,
654 isDragging,
655 isOver,
656 } = useSortable({ id: projectId, disabled });
657 return (
658 <li
659 ref={setNodeRef}
660 style={{
661 transform: CSS.Translate.toString(transform),
662 transition,
663 }}
664 className={`group/menu-item relative rounded-md ${
665 isDragging ? "z-20 opacity-80" : ""
666 } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`}
667 data-sidebar="menu-item"
668 data-slot="sidebar-menu-item"
669 >
670 {children({ attributes, listeners, setActivatorNodeRef })}
671 </li>
896 project,
897 isThreadListExpanded,
898 activeRouteThreadKey,
899 newThreadShortcutLabel,
900 handleNewThread,
901 archiveThread,
902 deleteThread,
903 threadJumpLabelByKey,
904 attachThreadListAutoAnimateRef,
905 expandThreadListForProject,
906 collapseThreadListForProject,
907 dragInProgressRef,
908 suppressProjectClickAfterDragRef,
909 suppressProjectClickForContextMenuRef,
910 isManualProjectSorting,
911 dragHandleProps,
912 } = props;
913 const threadSortOrder = useSettings<SidebarThreadSortOrder>(
914 (settings) => settings.sidebarThreadSortOrder,
672915 );
673}
674
675export default function Sidebar() {
676 const projectIds = useStore((store) => store.projectIds);
677 const projectById = useStore((store) => store.projectById);
678 const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById);
679 const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId);
680 const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore(
681 useShallow((store) => ({
682 projectExpandedById: store.projectExpandedById,
683 projectOrder: store.projectOrder,
684 threadLastVisitedAtById: store.threadLastVisitedAtById,
685 })),
686 );
687 const markThreadUnread = useUiStateStore((store) => store.markThreadUnread);
688 const toggleProject = useUiStateStore((store) => store.toggleProject);
689 const reorderProjects = useUiStateStore((store) => store.reorderProjects);
690 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
691 const getDraftThreadByProjectId = useComposerDraftStore(
692 (store) => store.getDraftThreadByProjectId,
916 const appSettingsConfirmThreadDelete = useSettings<boolean>(
917 (settings) => settings.confirmThreadDelete,
918 );
919 const appSettingsConfirmThreadArchive = useSettings<boolean>(
920 (settings) => settings.confirmThreadArchive,
921 );
922 const defaultThreadEnvMode = useSettings<ThreadEnvMode>(
923 (settings) => settings.defaultThreadEnvMode,
924 );
925 const router = useRouter();
926 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
927 const toggleProject = useUiStateStore((state) => state.toggleProject);
928 const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread);
929 const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo);
930 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
931 const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection);
932 const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor);
933 const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size);
934 const clearComposerDraftForThread = useComposerDraftStore((state) => state.clearDraftThread);
935 const getDraftThreadByProjectRef = useComposerDraftStore(
936 (state) => state.getDraftThreadByProjectRef,
693937 );
694 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
695938 const clearProjectDraftThreadId = useComposerDraftStore(
696 (store) => store.clearProjectDraftThreadId,
939 (state) => state.clearProjectDraftThreadId,
697940 );
698 const navigate = useNavigate();
699 const pathname = useLocation({ select: (loc) => loc.pathname });
700 const isOnSettings = pathname.startsWith("/settings");
701 const appSettings = useSettings();
702 const { updateSettings } = useUpdateSettings();
703 const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread();
704 const { archiveThread, deleteThread } = useThreadActions();
705 const routeThreadId = useParams({
706 strict: false,
707 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
941 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
942 threadId: ThreadId;
943 }>({
944 onCopy: (ctx) => {
945 toastManager.add({
946 type: "success",
947 title: "Thread ID copied",
948 description: ctx.threadId,
949 });
950 },
951 onError: (error) => {
952 toastManager.add({
953 type: "error",
954 title: "Failed to copy thread ID",
955 description: error instanceof Error ? error.message : "An error occurred.",
956 });
957 },
708958 });
709 const keybindings = useServerKeybindings();
710 const [addingProject, setAddingProject] = useState(false);
711 const [newCwd, setNewCwd] = useState("");
712 const [isPickingFolder, setIsPickingFolder] = useState(false);
713 const [isAddingProject, setIsAddingProject] = useState(false);
714 const [addProjectError, setAddProjectError] = useState<string | null>(null);
715 const addProjectInputRef = useRef<HTMLInputElement | null>(null);
716 const [renamingProjectId, setRenamingProjectId] = useState<ProjectId | null>(null);
717 const [renamingProjectTitle, setRenamingProjectTitle] = useState("");
718 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
719 const [renamingTitle, setRenamingTitle] = useState("");
720 const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);
721 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
722 ReadonlySet<ProjectId>
723 >(() => new Set());
724 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
725 const projectRenamingCommittedRef = useRef(false);
726 const projectRenamingInputRef = useRef<HTMLInputElement | null>(null);
727 const renamingCommittedRef = useRef(false);
728 const renamingInputRef = useRef<HTMLInputElement | null>(null);
729 const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>());
730 const dragInProgressRef = useRef(false);
731 const suppressProjectClickAfterDragRef = useRef(false);
732 const suppressProjectClickForContextMenuRef = useRef(false);
733 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
734 const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds);
735 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
736 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
737 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
738 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
739 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
740 const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform);
741 const platform = navigator.platform;
742 const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
743 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
744 const projects = useMemo(
745 () =>
746 projectIds.flatMap((projectId) => {
747 const project = projectById[projectId];
748 return project ? [project] : [];
749 }),
750 [projectById, projectIds],
751 );
752 const orderedProjects = useMemo(() => {
753 return orderItemsByPreferredIds({
754 items: projects,
755 preferredIds: projectOrder,
756 getId: (project) => project.id,
757 });
758 }, [projectOrder, projects]);
759 const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(
760 () =>
761 orderedProjects.map((project) => ({
762 ...project,
763 expanded: projectExpandedById[project.id] ?? true,
764 })),
765 [orderedProjects, projectExpandedById],
766 );
767 const sidebarThreads = useMemo(() => Object.values(sidebarThreadsById), [sidebarThreadsById]);
768 const projectCwdById = useMemo(
769 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
770 [projects],
771 );
772 const routeTerminalOpen = routeThreadId
773 ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen
774 : false;
775 const sidebarShortcutLabelOptions = useMemo(
776 () => ({
777 platform,
778 context: {
779 terminalFocus: false,
780 terminalOpen: routeTerminalOpen,
781 },
782 }),
783 [platform, routeTerminalOpen],
784 );
785 const openPrLink = useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => {
959 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
960 path: string;
961 }>({
962 onCopy: (ctx) => {
963 toastManager.add({
964 type: "success",
965 title: "Path copied",
966 description: ctx.path,
967 });
968 },
969 onError: (error) => {
970 toastManager.add({
971 type: "error",
972 title: "Failed to copy path",
973 description: error instanceof Error ? error.message : "An error occurred.",
974 });
975 },
976 });
977 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
786978 event.preventDefault();
787979 event.stopPropagation();
788980
789 const api = readNativeApi();
981 const api = readLocalApi();
790982 if (!api) {
791983 toastManager.add({
792984 type: "error",
......@@ -803,222 +995,495 @@ export default function Sidebar() {
803995 });
804996 });
805997 }, []);
998 const sidebarThreads = useStore(
999 useShallow(
1000 useMemo(
1001 () => (state) =>
1002 selectSidebarThreadsForProjectRef(
1003 state,
1004 scopeProjectRef(project.environmentId, project.id),
1005 ),
1006 [project.environmentId, project.id],
1007 ),
1008 ),
1009 );
1010 const sidebarThreadByKey = useMemo(
1011 () =>
1012 new Map(
1013 sidebarThreads.map(
1014 (thread) =>
1015 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
1016 ),
1017 ),
1018 [sidebarThreads],
1019 );
1020 const projectThreads = useMemo(
1021 () =>
1022 sidebarThreads.filter(
1023 (thread) =>
1024 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)) ===
1025 project.projectKey,
1026 ),
1027 [project.projectKey, sidebarThreads],
1028 );
1029 const projectExpanded = useUiStateStore(
1030 (state) => state.projectExpandedById[project.projectKey] ?? true,
1031 );
1032 const threadLastVisitedAts = useUiStateStore(
1033 useShallow((state) =>
1034 projectThreads.map(
1035 (thread) =>
1036 state.threadLastVisitedAtById[
1037 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))
1038 ] ?? null,
1039 ),
1040 ),
1041 );
1042 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
1043 const [renamingTitle, setRenamingTitle] = useState("");
1044 const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null);
1045 const renamingCommittedRef = useRef(false);
1046 const renamingInputRef = useRef<HTMLInputElement | null>(null);
1047 const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>());
1048
1049 const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => {
1050 const lastVisitedAtByThreadKey = new Map(
1051 projectThreads.map((thread, index) => [
1052 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1053 threadLastVisitedAts[index] ?? null,
1054 ]),
1055 );
1056 const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => {
1057 const lastVisitedAt = lastVisitedAtByThreadKey.get(
1058 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1059 );
1060 return resolveThreadStatusPill({
1061 thread: {
1062 ...thread,
1063 ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
1064 },
1065 });
1066 };
1067 const visibleProjectThreads = sortThreadsForSidebar(
1068 projectThreads.filter((thread) => thread.archivedAt === null),
1069 threadSortOrder,
1070 );
1071 const projectStatus = resolveProjectStatusIndicator(
1072 visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)),
1073 );
1074 return {
1075 orderedProjectThreadKeys: visibleProjectThreads.map((thread) =>
1076 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1077 ),
1078 projectStatus,
1079 visibleProjectThreads,
1080 };
1081 }, [projectThreads, threadLastVisitedAts, threadSortOrder]);
8061082
807 const attemptArchiveThread = useCallback(
808 async (threadId: ThreadId) => {
809 try {
810 await archiveThread(threadId);
811 } catch (error) {
812 toastManager.add({
813 type: "error",
814 title: "Failed to archive thread",
815 description: error instanceof Error ? error.message : "An error occurred.",
816 });
1083 const pinnedCollapsedThread = useMemo(() => {
1084 const activeThreadKey = activeRouteThreadKey ?? undefined;
1085 if (!activeThreadKey || projectExpanded) {
1086 return null;
1087 }
1088 return (
1089 visibleProjectThreads.find(
1090 (thread) =>
1091 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey,
1092 ) ?? null
1093 );
1094 }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]);
1095
1096 const {
1097 hasOverflowingThreads,
1098 hiddenThreadStatus,
1099 renderedThreads,
1100 showEmptyThreadState,
1101 shouldShowThreadPanel,
1102 } = useMemo(() => {
1103 const lastVisitedAtByThreadKey = new Map(
1104 projectThreads.map((thread, index) => [
1105 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1106 threadLastVisitedAts[index] ?? null,
1107 ]),
1108 );
1109 const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => {
1110 const lastVisitedAt = lastVisitedAtByThreadKey.get(
1111 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1112 );
1113 return resolveThreadStatusPill({
1114 thread: {
1115 ...thread,
1116 ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
1117 },
1118 });
1119 };
1120 const hasOverflowingThreads = visibleProjectThreads.length > THREAD_PREVIEW_LIMIT;
1121 const previewThreads =
1122 isThreadListExpanded || !hasOverflowingThreads
1123 ? visibleProjectThreads
1124 : visibleProjectThreads.slice(0, THREAD_PREVIEW_LIMIT);
1125 const visibleThreadKeys = new Set(
1126 [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) =>
1127 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1128 ),
1129 );
1130 const renderedThreads = pinnedCollapsedThread
1131 ? [pinnedCollapsedThread]
1132 : visibleProjectThreads.filter((thread) =>
1133 visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1134 );
1135 const hiddenThreads = visibleProjectThreads.filter(
1136 (thread) =>
1137 !visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1138 );
1139 return {
1140 hasOverflowingThreads,
1141 hiddenThreadStatus: resolveProjectStatusIndicator(
1142 hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)),
1143 ),
1144 renderedThreads,
1145 showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0,
1146 shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null,
1147 };
1148 }, [
1149 isThreadListExpanded,
1150 pinnedCollapsedThread,
1151 projectExpanded,
1152 projectThreads,
1153 threadLastVisitedAts,
1154 visibleProjectThreads,
1155 ]);
1156
1157 const handleProjectButtonClick = useCallback(
1158 (event: React.MouseEvent<HTMLButtonElement>) => {
1159 if (suppressProjectClickForContextMenuRef.current) {
1160 suppressProjectClickForContextMenuRef.current = false;
1161 event.preventDefault();
1162 event.stopPropagation();
1163 return;
1164 }
1165 if (dragInProgressRef.current) {
1166 event.preventDefault();
1167 event.stopPropagation();
1168 return;
1169 }
1170 if (suppressProjectClickAfterDragRef.current) {
1171 suppressProjectClickAfterDragRef.current = false;
1172 event.preventDefault();
1173 event.stopPropagation();
1174 return;
1175 }
1176 if (selectedThreadCount > 0) {
1177 clearSelection();
8171178 }
1179 toggleProject(project.projectKey);
8181180 },
819 [archiveThread],
1181 [
1182 clearSelection,
1183 dragInProgressRef,
1184 project.projectKey,
1185 selectedThreadCount,
1186 suppressProjectClickAfterDragRef,
1187 suppressProjectClickForContextMenuRef,
1188 toggleProject,
1189 ],
8201190 );
8211191
822 const focusMostRecentThreadForProject = useCallback(
823 (projectId: ProjectId) => {
824 const latestThread = sortThreadsForSidebar(
825 (threadIdsByProjectId[projectId] ?? [])
826 .map((threadId) => sidebarThreadsById[threadId])
827 .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined)
828 .filter((thread) => thread.archivedAt === null),
829 appSettings.sidebarThreadSortOrder,
830 )[0];
831 if (!latestThread) return;
832
833 void navigate({
834 to: "/$threadId",
835 params: { threadId: latestThread.id },
836 });
1192 const handleProjectButtonKeyDown = useCallback(
1193 (event: React.KeyboardEvent<HTMLButtonElement>) => {
1194 if (event.key !== "Enter" && event.key !== " ") return;
1195 event.preventDefault();
1196 if (dragInProgressRef.current) {
1197 return;
1198 }
1199 toggleProject(project.projectKey);
8371200 },
838 [appSettings.sidebarThreadSortOrder, navigate, sidebarThreadsById, threadIdsByProjectId],
1201 [dragInProgressRef, project.projectKey, toggleProject],
8391202 );
8401203
841 const addProjectFromPath = useCallback(
842 async (rawCwd: string) => {
843 const cwd = rawCwd.trim();
844 if (!cwd || isAddingProject) return;
845 const api = readNativeApi();
846 if (!api) return;
1204 const handleProjectButtonPointerDownCapture = useCallback(
1205 (event: React.PointerEvent<HTMLButtonElement>) => {
1206 suppressProjectClickForContextMenuRef.current = false;
1207 if (
1208 isContextMenuPointerDown({
1209 button: event.button,
1210 ctrlKey: event.ctrlKey,
1211 isMac: isMacPlatform(navigator.platform),
1212 })
1213 ) {
1214 event.stopPropagation();
1215 }
8471216
848 setIsAddingProject(true);
849 const finishAddingProject = () => {
850 setIsAddingProject(false);
851 setNewCwd("");
852 setAddProjectError(null);
853 setAddingProject(false);
854 };
1217 suppressProjectClickAfterDragRef.current = false;
1218 },
1219 [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef],
1220 );
8551221
856 const existing = projects.find((project) => project.cwd === cwd);
857 if (existing) {
858 focusMostRecentThreadForProject(existing.id);
859 finishAddingProject();
860 return;
861 }
1222 const handleProjectButtonContextMenu = useCallback(
1223 (event: React.MouseEvent<HTMLButtonElement>) => {
1224 event.preventDefault();
1225 suppressProjectClickForContextMenuRef.current = true;
1226 void (async () => {
1227 const api = readLocalApi();
1228 if (!api) return;
8621229
863 const projectId = newProjectId();
864 const createdAt = new Date().toISOString();
865 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
866 try {
867 await api.orchestration.dispatchCommand({
868 type: "project.create",
869 commandId: newCommandId(),
870 projectId,
871 title,
872 workspaceRoot: cwd,
873 defaultModelSelection: {
874 provider: "codex",
875 model: DEFAULT_MODEL_BY_PROVIDER.codex,
1230 const clicked = await api.contextMenu.show(
1231 [
1232 { id: "copy-path", label: "Copy Project Path" },
1233 { id: "delete", label: "Remove project", destructive: true },
1234 ],
1235 {
1236 x: event.clientX,
1237 y: event.clientY,
8761238 },
877 createdAt,
878 });
879 await handleNewThread(projectId, {
880 envMode: appSettings.defaultThreadEnvMode,
881 }).catch(() => undefined);
882 } catch (error) {
883 const description =
884 error instanceof Error ? error.message : "An error occurred while adding the project.";
885 setIsAddingProject(false);
886 if (shouldBrowseForProjectImmediately) {
1239 );
1240 if (clicked === "copy-path") {
1241 copyPathToClipboard(project.cwd, { path: project.cwd });
1242 return;
1243 }
1244 if (clicked !== "delete") return;
1245
1246 if (projectThreads.length > 0) {
1247 toastManager.add({
1248 type: "warning",
1249 title: "Project is not empty",
1250 description: "Delete all threads in this project before removing it.",
1251 });
1252 return;
1253 }
1254
1255 const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`);
1256 if (!confirmed) return;
1257
1258 try {
1259 const projectDraftThread = getDraftThreadByProjectRef(
1260 scopeProjectRef(project.environmentId, project.id),
1261 );
1262 if (projectDraftThread) {
1263 clearComposerDraftForThread(projectDraftThread.draftId);
1264 }
1265 clearProjectDraftThreadId(scopeProjectRef(project.environmentId, project.id));
1266 const projectApi = readEnvironmentApi(project.environmentId);
1267 if (!projectApi) {
1268 throw new Error("Project API unavailable.");
1269 }
1270 await projectApi.orchestration.dispatchCommand({
1271 type: "project.delete",
1272 commandId: newCommandId(),
1273 projectId: project.id,
1274 });
1275 } catch (error) {
1276 const message =
1277 error instanceof Error ? error.message : "Unknown error removing project.";
1278 console.error("Failed to remove project", { projectId: project.id, error });
8871279 toastManager.add({
8881280 type: "error",
889 title: "Failed to add project",
890 description,
1281 title: `Failed to remove "${project.name}"`,
1282 description: message,
8911283 });
892 } else {
893 setAddProjectError(description);
8941284 }
895 return;
896 }
897 finishAddingProject();
1285 })();
8981286 },
8991287 [
900 focusMostRecentThreadForProject,
901 handleNewThread,
902 isAddingProject,
903 projects,
904 shouldBrowseForProjectImmediately,
905 appSettings.defaultThreadEnvMode,
1288 clearComposerDraftForThread,
1289 clearProjectDraftThreadId,
1290 copyPathToClipboard,
1291 getDraftThreadByProjectRef,
1292 project.cwd,
1293 project.environmentId,
1294 project.id,
1295 project.name,
1296 projectThreads.length,
1297 suppressProjectClickForContextMenuRef,
9061298 ],
9071299 );
9081300
909 const handleAddProject = () => {
910 void addProjectFromPath(newCwd);
911 };
1301 const navigateToThread = useCallback(
1302 (threadRef: ScopedThreadRef) => {
1303 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
1304 clearSelection();
1305 }
1306 setSelectionAnchor(scopedThreadKey(threadRef));
1307 void router.navigate({
1308 to: "/$environmentId/$threadId",
1309 params: buildThreadRouteParams(threadRef),
1310 });
1311 },
1312 [clearSelection, router, setSelectionAnchor],
1313 );
9121314
913 const canAddProject = newCwd.trim().length > 0 && !isAddingProject;
1315 const handleThreadClick = useCallback(
1316 (
1317 event: React.MouseEvent,
1318 threadRef: ScopedThreadRef,
1319 orderedProjectThreadKeys: readonly string[],
1320 ) => {
1321 const isMac = isMacPlatform(navigator.platform);
1322 const isModClick = isMac ? event.metaKey : event.ctrlKey;
1323 const isShiftClick = event.shiftKey;
1324 const threadKey = scopedThreadKey(threadRef);
1325 const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size;
9141326
915 const handlePickFolder = async () => {
916 const api = readNativeApi();
917 if (!api || isPickingFolder) return;
918 setIsPickingFolder(true);
919 let pickedPath: string | null = null;
920 try {
921 pickedPath = await api.dialogs.pickFolder();
922 } catch {
923 // Ignore picker failures and leave the current thread selection unchanged.
924 }
925 if (pickedPath) {
926 await addProjectFromPath(pickedPath);
927 } else if (!shouldBrowseForProjectImmediately) {
928 addProjectInputRef.current?.focus();
929 }
930 setIsPickingFolder(false);
931 };
932
933 const handleStartAddProject = () => {
934 setAddProjectError(null);
935 if (shouldBrowseForProjectImmediately) {
936 void handlePickFolder();
937 return;
938 }
939 setAddingProject((prev) => !prev);
940 };
941
942 const cancelRename = useCallback(() => {
943 setRenamingThreadId(null);
944 renamingInputRef.current = null;
945 }, []);
1327 if (isModClick) {
1328 event.preventDefault();
1329 toggleThreadSelection(threadKey);
1330 return;
1331 }
9461332
947 const handleRenamingInputMount = useCallback((element: HTMLInputElement | null) => {
948 if (element && renamingInputRef.current !== element) {
949 renamingInputRef.current = element;
950 element.focus();
951 element.select();
952 return;
953 }
954 if (element === null && renamingInputRef.current !== null) {
955 renamingInputRef.current = null;
956 }
957 }, []);
1333 if (isShiftClick) {
1334 event.preventDefault();
1335 rangeSelectTo(threadKey, orderedProjectThreadKeys);
1336 return;
1337 }
9581338
959 const hasRenameCommitted = useCallback(() => renamingCommittedRef.current, []);
960 const markRenameCommitted = useCallback(() => {
961 renamingCommittedRef.current = true;
962 }, []);
1339 if (currentSelectionCount > 0) {
1340 clearSelection();
1341 }
1342 setSelectionAnchor(threadKey);
1343 void router.navigate({
1344 to: "/$environmentId/$threadId",
1345 params: buildThreadRouteParams(threadRef),
1346 });
1347 },
1348 [clearSelection, rangeSelectTo, router, setSelectionAnchor, toggleThreadSelection],
1349 );
9631350
964 const cancelProjectRename = useCallback(() => {
965 setRenamingProjectId(null);
966 projectRenamingInputRef.current = null;
967 }, []);
1351 const handleMultiSelectContextMenu = useCallback(
1352 async (position: { x: number; y: number }) => {
1353 const api = readLocalApi();
1354 if (!api) return;
1355 const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
1356 if (threadKeys.length === 0) return;
1357 const count = threadKeys.length;
9681358
969 const commitRename = useCallback(
970 async (threadId: ThreadId, newTitle: string, originalTitle: string) => {
971 const finishRename = () => {
972 setRenamingThreadId((current) => {
973 if (current !== threadId) return current;
974 renamingInputRef.current = null;
975 return null;
976 });
977 };
1359 const clicked = await api.contextMenu.show(
1360 [
1361 { id: "mark-unread", label: `Mark unread (${count})` },
1362 { id: "delete", label: `Delete (${count})`, destructive: true },
1363 ],
1364 position,
1365 );
9781366
979 const trimmed = newTitle.trim();
980 if (trimmed.length === 0) {
981 toastManager.add({
982 type: "warning",
983 title: "Thread title cannot be empty",
984 });
985 finishRename();
1367 if (clicked === "mark-unread") {
1368 for (const threadKey of threadKeys) {
1369 const thread = sidebarThreadByKey.get(threadKey);
1370 markThreadUnread(threadKey, thread?.latestTurn?.completedAt);
1371 }
1372 clearSelection();
9861373 return;
9871374 }
988 if (trimmed === originalTitle) {
989 finishRename();
990 return;
1375
1376 if (clicked !== "delete") return;
1377
1378 if (appSettingsConfirmThreadDelete) {
1379 const confirmed = await api.dialogs.confirm(
1380 [
1381 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
1382 "This permanently clears conversation history for these threads.",
1383 ].join("\n"),
1384 );
1385 if (!confirmed) return;
9911386 }
992 const api = readNativeApi();
993 if (!api) {
994 finishRename();
995 return;
1387
1388 const deletedThreadKeys = new Set(threadKeys);
1389 for (const threadKey of threadKeys) {
1390 const thread = sidebarThreadByKey.get(threadKey);
1391 if (!thread) continue;
1392 await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
1393 deletedThreadKeys,
1394 });
9961395 }
1396 removeFromSelection(threadKeys);
1397 },
1398 [
1399 appSettingsConfirmThreadDelete,
1400 clearSelection,
1401 deleteThread,
1402 markThreadUnread,
1403 removeFromSelection,
1404 sidebarThreadByKey,
1405 ],
1406 );
1407
1408 const handleCreateThreadClick = useCallback(
1409 (event: React.MouseEvent<HTMLButtonElement>) => {
1410 event.preventDefault();
1411 event.stopPropagation();
1412 const currentRouteParams =
1413 router.state.matches[router.state.matches.length - 1]?.params ?? {};
1414 const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams);
1415 const currentActiveThread =
1416 currentRouteTarget?.kind === "server"
1417 ? (selectThreadByRef(useStore.getState(), currentRouteTarget.threadRef) ?? null)
1418 : null;
1419 const draftStore = useComposerDraftStore.getState();
1420 const currentActiveDraftThread =
1421 currentRouteTarget?.kind === "server"
1422 ? (draftStore.getDraftThread(currentRouteTarget.threadRef) ?? null)
1423 : currentRouteTarget?.kind === "draft"
1424 ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null)
1425 : null;
1426 const seedContext = resolveSidebarNewThreadSeedContext({
1427 projectId: project.id,
1428 defaultEnvMode: resolveSidebarNewThreadEnvMode({
1429 defaultEnvMode: defaultThreadEnvMode,
1430 }),
1431 activeThread:
1432 currentActiveThread && currentActiveThread.projectId === project.id
1433 ? {
1434 projectId: currentActiveThread.projectId,
1435 branch: currentActiveThread.branch,
1436 worktreePath: currentActiveThread.worktreePath,
1437 }
1438 : null,
1439 activeDraftThread:
1440 currentActiveDraftThread && currentActiveDraftThread.projectId === project.id
1441 ? {
1442 projectId: currentActiveDraftThread.projectId,
1443 branch: currentActiveDraftThread.branch,
1444 worktreePath: currentActiveDraftThread.worktreePath,
1445 envMode: currentActiveDraftThread.envMode,
1446 }
1447 : null,
1448 });
1449 void handleNewThread(scopeProjectRef(project.environmentId, project.id), {
1450 ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}),
1451 ...(seedContext.worktreePath !== undefined
1452 ? { worktreePath: seedContext.worktreePath }
1453 : {}),
1454 envMode: seedContext.envMode,
1455 });
1456 },
1457 [defaultThreadEnvMode, handleNewThread, project.environmentId, project.id, router],
1458 );
1459
1460 const attemptArchiveThread = useCallback(
1461 async (threadRef: ScopedThreadRef) => {
9971462 try {
998 await api.orchestration.dispatchCommand({
999 type: "thread.meta.update",
1000 commandId: newCommandId(),
1001 threadId,
1002 title: trimmed,
1003 });
1463 await archiveThread(threadRef);
10041464 } catch (error) {
10051465 toastManager.add({
10061466 type: "error",
1007 title: "Failed to rename thread",
1467 title: "Failed to archive thread",
10081468 description: error instanceof Error ? error.message : "An error occurred.",
10091469 });
10101470 }
1011 finishRename();
10121471 },
1013 [],
1472 [archiveThread],
10141473 );
10151474
1016 const commitProjectRename = useCallback(
1017 async (projectId: ProjectId, newTitle: string, originalTitle: string) => {
1475 const cancelRename = useCallback(() => {
1476 setRenamingThreadKey(null);
1477 renamingInputRef.current = null;
1478 }, []);
1479
1480 const commitRename = useCallback(
1481 async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => {
1482 const threadKey = scopedThreadKey(threadRef);
10181483 const finishRename = () => {
1019 setRenamingProjectId((current) => {
1020 if (current !== projectId) return current;
1021 projectRenamingInputRef.current = null;
1484 setRenamingThreadKey((current) => {
1485 if (current !== threadKey) return current;
1486 renamingInputRef.current = null;
10221487 return null;
10231488 });
10241489 };
......@@ -1027,7 +1492,7 @@ export default function Sidebar() {
10271492 if (trimmed.length === 0) {
10281493 toastManager.add({
10291494 type: "warning",
1030 title: "Project title cannot be empty",
1495 title: "Thread title cannot be empty",
10311496 });
10321497 finishRename();
10331498 return;
......@@ -1036,22 +1501,22 @@ export default function Sidebar() {
10361501 finishRename();
10371502 return;
10381503 }
1039 const api = readNativeApi();
1504 const api = readEnvironmentApi(threadRef.environmentId);
10401505 if (!api) {
10411506 finishRename();
10421507 return;
10431508 }
10441509 try {
10451510 await api.orchestration.dispatchCommand({
1046 type: "project.meta.update",
1511 type: "thread.meta.update",
10471512 commandId: newCommandId(),
1048 projectId,
1513 threadId: threadRef.threadId,
10491514 title: trimmed,
10501515 });
10511516 } catch (error) {
10521517 toastManager.add({
10531518 type: "error",
1054 title: "Failed to rename project",
1519 title: "Failed to rename thread",
10551520 description: error instanceof Error ? error.message : "An error occurred.",
10561521 });
10571522 }
......@@ -1060,50 +1525,19 @@ export default function Sidebar() {
10601525 [],
10611526 );
10621527
1063 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
1064 threadId: ThreadId;
1065 }>({
1066 onCopy: (ctx) => {
1067 toastManager.add({
1068 type: "success",
1069 title: "Thread ID copied",
1070 description: ctx.threadId,
1071 });
1072 },
1073 onError: (error) => {
1074 toastManager.add({
1075 type: "error",
1076 title: "Failed to copy thread ID",
1077 description: error instanceof Error ? error.message : "An error occurred.",
1078 });
1079 },
1080 });
1081 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
1082 path: string;
1083 }>({
1084 onCopy: (ctx) => {
1085 toastManager.add({
1086 type: "success",
1087 title: "Path copied",
1088 description: ctx.path,
1089 });
1090 },
1091 onError: (error) => {
1092 toastManager.add({
1093 type: "error",
1094 title: "Failed to copy path",
1095 description: error instanceof Error ? error.message : "An error occurred.",
1096 });
1097 },
1098 });
10991528 const handleThreadContextMenu = useCallback(
1100 async (threadId: ThreadId, position: { x: number; y: number }) => {
1101 const api = readNativeApi();
1529 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
1530 const api = readLocalApi();
11021531 if (!api) return;
1103 const thread = sidebarThreadsById[threadId];
1532 const threadKey = scopedThreadKey(threadRef);
1533 const thread =
1534 projectThreads.find(
1535 (projectThread) =>
1536 projectThread.environmentId === threadRef.environmentId &&
1537 projectThread.id === threadRef.threadId,
1538 ) ?? null;
11041539 if (!thread) return;
1105 const threadWorkspacePath =
1106 thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null;
1540 const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null;
11071541 const clicked = await api.contextMenu.show(
11081542 [
11091543 { id: "rename", label: "Rename thread" },
......@@ -1116,16 +1550,14 @@ export default function Sidebar() {
11161550 );
11171551
11181552 if (clicked === "rename") {
1119 setRenamingProjectId(null);
1120 projectRenamingInputRef.current = null;
1121 setRenamingThreadId(threadId);
1553 setRenamingThreadKey(threadKey);
11221554 setRenamingTitle(thread.title);
11231555 renamingCommittedRef.current = false;
11241556 return;
11251557 }
11261558
11271559 if (clicked === "mark-unread") {
1128 markThreadUnread(threadId, thread.latestTurn?.completedAt);
1560 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
11291561 return;
11301562 }
11311563 if (clicked === "copy-path") {
......@@ -1141,11 +1573,11 @@ export default function Sidebar() {
11411573 return;
11421574 }
11431575 if (clicked === "copy-thread-id") {
1144 copyThreadIdToClipboard(threadId, { threadId });
1576 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
11451577 return;
11461578 }
11471579 if (clicked !== "delete") return;
1148 if (appSettings.confirmThreadDelete) {
1580 if (appSettingsConfirmThreadDelete) {
11491581 const confirmed = await api.dialogs.confirm(
11501582 [
11511583 `Delete thread "${thread.title}"?`,
......@@ -1156,198 +1588,903 @@ export default function Sidebar() {
11561588 return;
11571589 }
11581590 }
1159 await deleteThread(threadId);
1591 await deleteThread(threadRef);
11601592 },
11611593 [
1162 appSettings.confirmThreadDelete,
1594 appSettingsConfirmThreadDelete,
11631595 copyPathToClipboard,
11641596 copyThreadIdToClipboard,
11651597 deleteThread,
11661598 markThreadUnread,
1167 projectCwdById,
1168 sidebarThreadsById,
1599 project.cwd,
1600 projectThreads,
11691601 ],
11701602 );
11711603
1172 const handleMultiSelectContextMenu = useCallback(
1173 async (position: { x: number; y: number }) => {
1174 const api = readNativeApi();
1175 if (!api) return;
1176 const ids = [...selectedThreadIds];
1177 if (ids.length === 0) return;
1178 const count = ids.length;
1179
1180 const clicked = await api.contextMenu.show(
1181 [
1182 { id: "mark-unread", label: `Mark unread (${count})` },
1183 { id: "delete", label: `Delete (${count})`, destructive: true },
1184 ],
1185 position,
1186 );
1187
1188 if (clicked === "mark-unread") {
1189 for (const id of ids) {
1190 const thread = sidebarThreadsById[id];
1191 markThreadUnread(id, thread?.latestTurn?.completedAt);
1192 }
1193 clearSelection();
1194 return;
1195 }
1196
1197 if (clicked !== "delete") return;
1198
1199 if (appSettings.confirmThreadDelete) {
1200 const confirmed = await api.dialogs.confirm(
1201 [
1202 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
1203 "This permanently clears conversation history for these threads.",
1204 ].join("\n"),
1205 );
1206 if (!confirmed) return;
1207 }
1208
1209 const deletedIds = new Set<ThreadId>(ids);
1210 for (const id of ids) {
1211 await deleteThread(id, { deletedThreadIds: deletedIds });
1212 }
1213 removeFromSelection(ids);
1214 },
1215 [
1216 appSettings.confirmThreadDelete,
1217 clearSelection,
1218 deleteThread,
1219 markThreadUnread,
1220 removeFromSelection,
1221 selectedThreadIds,
1222 sidebarThreadsById,
1223 ],
1604 return (
1605 <>
1606 <div className="group/project-header relative">
1607 <SidebarMenuButton
1608 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
1609 size="sm"
1610 className={`gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${
1611 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
1612 }`}
1613 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
1614 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
1615 onPointerDownCapture={handleProjectButtonPointerDownCapture}
1616 onClick={handleProjectButtonClick}
1617 onKeyDown={handleProjectButtonKeyDown}
1618 onContextMenu={handleProjectButtonContextMenu}
1619 >
1620 {!projectExpanded && projectStatus ? (
1621 <span
1622 aria-hidden="true"
1623 title={projectStatus.label}
1624 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
1625 >
1626 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
1627 <span
1628 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
1629 projectStatus.pulse ? "animate-pulse" : ""
1630 }`}
1631 />
1632 </span>
1633 <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
1634 </span>
1635 ) : (
1636 <ChevronRightIcon
1637 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1638 projectExpanded ? "rotate-90" : ""
1639 }`}
1640 />
1641 )}
1642 <ProjectFavicon cwd={project.cwd} />
1643 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1644 {project.name}
1645 </span>
1646 </SidebarMenuButton>
1647 <Tooltip>
1648 <TooltipTrigger
1649 render={
1650 <SidebarMenuAction
1651 render={
1652 <button
1653 type="button"
1654 aria-label={`Create new thread in ${project.name}`}
1655 data-testid="new-thread-button"
1656 />
1657 }
1658 showOnHover
1659 className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1660 onClick={handleCreateThreadClick}
1661 >
1662 <SquarePenIcon className="size-3.5" />
1663 </SidebarMenuAction>
1664 }
1665 />
1666 <TooltipPopup side="top">
1667 {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
1668 </TooltipPopup>
1669 </Tooltip>
1670 </div>
1671
1672 <SidebarProjectThreadList
1673 projectKey={project.projectKey}
1674 projectExpanded={projectExpanded}
1675 hasOverflowingThreads={hasOverflowingThreads}
1676 hiddenThreadStatus={hiddenThreadStatus}
1677 orderedProjectThreadKeys={orderedProjectThreadKeys}
1678 renderedThreads={renderedThreads}
1679 showEmptyThreadState={showEmptyThreadState}
1680 shouldShowThreadPanel={shouldShowThreadPanel}
1681 isThreadListExpanded={isThreadListExpanded}
1682 projectCwd={project.cwd}
1683 activeRouteThreadKey={activeRouteThreadKey}
1684 threadJumpLabelByKey={threadJumpLabelByKey}
1685 appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
1686 renamingThreadKey={renamingThreadKey}
1687 renamingTitle={renamingTitle}
1688 setRenamingTitle={setRenamingTitle}
1689 renamingInputRef={renamingInputRef}
1690 renamingCommittedRef={renamingCommittedRef}
1691 confirmingArchiveThreadKey={confirmingArchiveThreadKey}
1692 setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
1693 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
1694 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
1695 handleThreadClick={handleThreadClick}
1696 navigateToThread={navigateToThread}
1697 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
1698 handleThreadContextMenu={handleThreadContextMenu}
1699 clearSelection={clearSelection}
1700 commitRename={commitRename}
1701 cancelRename={cancelRename}
1702 attemptArchiveThread={attemptArchiveThread}
1703 openPrLink={openPrLink}
1704 expandThreadListForProject={expandThreadListForProject}
1705 collapseThreadListForProject={collapseThreadListForProject}
1706 />
1707 </>
12241708 );
1709});
12251710
1226 const handleThreadClick = useCallback(
1227 (event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => {
1228 const isMac = isMacPlatform(navigator.platform);
1229 const isModClick = isMac ? event.metaKey : event.ctrlKey;
1230 const isShiftClick = event.shiftKey;
1231
1232 if (isModClick) {
1233 event.preventDefault();
1234 toggleThreadSelection(threadId);
1235 return;
1236 }
1237
1238 if (isShiftClick) {
1239 event.preventDefault();
1240 rangeSelectTo(threadId, orderedProjectThreadIds);
1241 return;
1242 }
1243
1244 // Plain click — clear selection, set anchor for future shift-clicks, and navigate
1245 if (selectedThreadIds.size > 0) {
1246 clearSelection();
1247 }
1248 setSelectionAnchor(threadId);
1249 void navigate({
1250 to: "/$threadId",
1251 params: { threadId },
1252 });
1253 },
1254 [
1255 clearSelection,
1256 navigate,
1257 rangeSelectTo,
1258 selectedThreadIds.size,
1259 setSelectionAnchor,
1260 toggleThreadSelection,
1261 ],
1711const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) {
1712 return (
1713 <SidebarMenuItem className="rounded-md">
1714 <SidebarProjectItem {...props} />
1715 </SidebarMenuItem>
12621716 );
1717});
12631718
1264 const navigateToThread = useCallback(
1265 (threadId: ThreadId) => {
1266 if (selectedThreadIds.size > 0) {
1267 clearSelection();
1268 }
1269 setSelectionAnchor(threadId);
1270 void navigate({
1271 to: "/$threadId",
1272 params: { threadId },
1273 });
1274 },
1275 [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor],
1719function T3Wordmark() {
1720 return (
1721 <svg
1722 aria-label="T3"
1723 className="h-2.5 w-auto shrink-0 text-foreground"
1724 viewBox="15.5309 37 94.3941 56.96"
1725 xmlns="http://www.w3.org/2000/svg"
1726 >
1727 <path
1728 d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
1729 fill="currentColor"
1730 />
1731 </svg>
12761732 );
1733}
12771734
1278 const handleProjectContextMenu = useCallback(
1279 async (projectId: ProjectId, position: { x: number; y: number }) => {
1280 const api = readNativeApi();
1281 if (!api) return;
1282 const project = projects.find((entry) => entry.id === projectId);
1283 if (!project) return;
1735type SortableProjectHandleProps = Pick<
1736 ReturnType<typeof useSortable>,
1737 "attributes" | "listeners" | "setActivatorNodeRef"
1738>;
12841739
1285 const clicked = await api.contextMenu.show(
1286 [
1287 { id: "rename", label: "Rename project" },
1288 { id: "copy-path", label: "Copy Project Path" },
1289 { id: "delete", label: "Remove project", destructive: true },
1290 ],
1291 position,
1292 );
1293 if (clicked === "rename") {
1294 setRenamingThreadId(null);
1295 renamingInputRef.current = null;
1296 setRenamingProjectId(projectId);
1297 setRenamingProjectTitle(project.name);
1298 projectRenamingCommittedRef.current = false;
1299 return;
1740function ProjectSortMenu({
1741 projectSortOrder,
1742 threadSortOrder,
1743 onProjectSortOrderChange,
1744 onThreadSortOrderChange,
1745}: {
1746 projectSortOrder: SidebarProjectSortOrder;
1747 threadSortOrder: SidebarThreadSortOrder;
1748 onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
1749 onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
1750}) {
1751 return (
1752 <Menu>
1753 <Tooltip>
1754 <TooltipTrigger
1755 render={
1756 <MenuTrigger className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" />
1757 }
1758 >
1759 <ArrowUpDownIcon className="size-3.5" />
1760 </TooltipTrigger>
1761 <TooltipPopup side="right">Sort projects</TooltipPopup>
1762 </Tooltip>
1763 <MenuPopup align="end" side="bottom" className="min-w-44">
1764 <MenuGroup>
1765 <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground">
1766 Sort projects
1767 </div>
1768 <MenuRadioGroup
1769 value={projectSortOrder}
1770 onValueChange={(value) => {
1771 onProjectSortOrderChange(value as SidebarProjectSortOrder);
1772 }}
1773 >
1774 {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map(
1775 ([value, label]) => (
1776 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
1777 {label}
1778 </MenuRadioItem>
1779 ),
1780 )}
1781 </MenuRadioGroup>
1782 </MenuGroup>
1783 <MenuGroup>
1784 <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground">
1785 Sort threads
1786 </div>
1787 <MenuRadioGroup
1788 value={threadSortOrder}
1789 onValueChange={(value) => {
1790 onThreadSortOrderChange(value as SidebarThreadSortOrder);
1791 }}
1792 >
1793 {(
1794 Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]>
1795 ).map(([value, label]) => (
1796 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
1797 {label}
1798 </MenuRadioItem>
1799 ))}
1800 </MenuRadioGroup>
1801 </MenuGroup>
1802 </MenuPopup>
1803 </Menu>
1804 );
1805}
1806
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);
13002035 }
1301 if (clicked === "copy-path") {
1302 copyPathToClipboard(project.cwd, { path: project.cwd });
1303 return;
2036 },
2037 [handleAddProject, setAddProjectError, setAddingProject],
2038 );
2039 const handleBrowseForFolderClick = useCallback(() => {
2040 void handlePickFolder();
2041 }, [handlePickFolder]);
2042
2043 return (
2044 <SidebarContent className="gap-0">
2045 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
2046 <SidebarGroup className="px-2 pt-2 pb-0">
2047 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
2048 <TriangleAlertIcon />
2049 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
2050 <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
2051 {desktopUpdateButtonAction !== "none" ? (
2052 <AlertAction>
2053 <Button
2054 size="xs"
2055 variant="outline"
2056 disabled={desktopUpdateButtonDisabled}
2057 onClick={handleDesktopUpdateButtonClick}
2058 >
2059 {desktopUpdateButtonAction === "download"
2060 ? "Download ARM build"
2061 : "Install ARM build"}
2062 </Button>
2063 </AlertAction>
2064 ) : null}
2065 </Alert>
2066 </SidebarGroup>
2067 ) : null}
2068 <SidebarGroup className="px-2 py-2">
2069 <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">
2070 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
2071 Projects
2072 </span>
2073 <div className="flex items-center gap-1">
2074 <ProjectSortMenu
2075 projectSortOrder={projectSortOrder}
2076 threadSortOrder={threadSortOrder}
2077 onProjectSortOrderChange={handleProjectSortOrderChange}
2078 onThreadSortOrderChange={handleThreadSortOrderChange}
2079 />
2080 <Tooltip>
2081 <TooltipTrigger
2082 render={
2083 <button
2084 type="button"
2085 aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
2086 aria-pressed={shouldShowProjectPathEntry}
2087 className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
2088 onClick={handleStartAddProject}
2089 />
2090 }
2091 >
2092 <PlusIcon
2093 className={`size-3.5 transition-transform duration-150 ${
2094 shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
2095 }`}
2096 />
2097 </TooltipTrigger>
2098 <TooltipPopup side="right">
2099 {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
2100 </TooltipPopup>
2101 </Tooltip>
2102 </div>
2103 </div>
2104 {shouldShowProjectPathEntry && (
2105 <div className="mb-2 px-1">
2106 {isElectron && (
2107 <button
2108 type="button"
2109 className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
2110 onClick={handleBrowseForFolderClick}
2111 disabled={isPickingFolder || isAddingProject}
2112 >
2113 <FolderIcon className="size-3.5" />
2114 {isPickingFolder ? "Picking folder..." : "Browse for folder"}
2115 </button>
2116 )}
2117 <div className="flex gap-1.5">
2118 <input
2119 ref={addProjectInputRef}
2120 className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${
2121 addProjectError
2122 ? "border-red-500/70 focus:border-red-500"
2123 : "border-border focus:border-ring"
2124 }`}
2125 placeholder="/path/to/project"
2126 value={newCwd}
2127 onChange={handleAddProjectInputChange}
2128 onKeyDown={handleAddProjectInputKeyDown}
2129 autoFocus
2130 />
2131 <button
2132 type="button"
2133 className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60"
2134 onClick={handleAddProject}
2135 disabled={!canAddProject}
2136 >
2137 {isAddingProject ? "Adding..." : "Add"}
2138 </button>
2139 </div>
2140 {addProjectError && (
2141 <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400">
2142 {addProjectError}
2143 </p>
2144 )}
2145 </div>
2146 )}
2147
2148 {isManualProjectSorting ? (
2149 <DndContext
2150 sensors={projectDnDSensors}
2151 collisionDetection={projectCollisionDetection}
2152 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
2153 onDragStart={handleProjectDragStart}
2154 onDragEnd={handleProjectDragEnd}
2155 onDragCancel={handleProjectDragCancel}
2156 >
2157 <SidebarMenu>
2158 <SortableContext
2159 items={sortedProjects.map((project) => project.projectKey)}
2160 strategy={verticalListSortingStrategy}
2161 >
2162 {sortedProjects.map((project) => (
2163 <SortableProjectItem key={project.projectKey} projectId={project.projectKey}>
2164 {(dragHandleProps) => (
2165 <SidebarProjectItem
2166 project={project}
2167 isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)}
2168 activeRouteThreadKey={
2169 activeRouteProjectKey === project.projectKey ? routeThreadKey : null
2170 }
2171 newThreadShortcutLabel={newThreadShortcutLabel}
2172 handleNewThread={handleNewThread}
2173 archiveThread={archiveThread}
2174 deleteThread={deleteThread}
2175 threadJumpLabelByKey={threadJumpLabelByKey}
2176 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2177 expandThreadListForProject={expandThreadListForProject}
2178 collapseThreadListForProject={collapseThreadListForProject}
2179 dragInProgressRef={dragInProgressRef}
2180 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
2181 suppressProjectClickForContextMenuRef={
2182 suppressProjectClickForContextMenuRef
2183 }
2184 isManualProjectSorting={isManualProjectSorting}
2185 dragHandleProps={dragHandleProps}
2186 />
2187 )}
2188 </SortableProjectItem>
2189 ))}
2190 </SortableContext>
2191 </SidebarMenu>
2192 </DndContext>
2193 ) : (
2194 <SidebarMenu ref={attachProjectListAutoAnimateRef}>
2195 {sortedProjects.map((project) => (
2196 <SidebarProjectListRow
2197 key={project.projectKey}
2198 project={project}
2199 isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)}
2200 activeRouteThreadKey={
2201 activeRouteProjectKey === project.projectKey ? routeThreadKey : null
2202 }
2203 newThreadShortcutLabel={newThreadShortcutLabel}
2204 handleNewThread={handleNewThread}
2205 archiveThread={archiveThread}
2206 deleteThread={deleteThread}
2207 threadJumpLabelByKey={threadJumpLabelByKey}
2208 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2209 expandThreadListForProject={expandThreadListForProject}
2210 collapseThreadListForProject={collapseThreadListForProject}
2211 dragInProgressRef={dragInProgressRef}
2212 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
2213 suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
2214 isManualProjectSorting={isManualProjectSorting}
2215 dragHandleProps={null}
2216 />
2217 ))}
2218 </SidebarMenu>
2219 )}
2220
2221 {projectsLength === 0 && !shouldShowProjectPathEntry && (
2222 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
2223 No projects yet
2224 </div>
2225 )}
2226 </SidebarGroup>
2227 </SidebarContent>
2228 );
2229});
2230
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]);
13042319 }
1305 if (clicked !== "delete") return;
2320 }
2321 return next;
2322 }, [sidebarThreads]);
2323 const getCurrentSidebarShortcutContext = useCallback(
2324 () => ({
2325 terminalFocus: isTerminalFocused(),
2326 terminalOpen: routeThreadRef
2327 ? selectThreadTerminalState(
2328 useTerminalStateStore.getState().terminalStateByThreadKey,
2329 routeThreadRef,
2330 ).terminalOpen
2331 : false,
2332 }),
2333 [routeThreadRef],
2334 );
2335 const newThreadShortcutLabelOptions = useMemo(
2336 () => ({
2337 platform,
2338 context: {
2339 terminalFocus: false,
2340 terminalOpen: false,
2341 },
2342 }),
2343 [platform],
2344 );
2345 const newThreadShortcutLabel =
2346 shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ??
2347 shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions);
2348 const focusMostRecentThreadForProject = useCallback(
2349 (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => {
2350 const projectKey = scopedProjectKey(
2351 scopeProjectRef(projectRef.environmentId, projectRef.projectId),
2352 );
2353 const latestThread = sortThreadsForSidebar(
2354 (threadsByProjectKey.get(projectKey) ?? []).filter((thread) => thread.archivedAt === null),
2355 appSettings.sidebarThreadSortOrder,
2356 )[0];
2357 if (!latestThread) return;
13062358
1307 const projectThreadIds = threadIdsByProjectId[projectId] ?? [];
1308 if (projectThreadIds.length > 0) {
1309 toastManager.add({
1310 type: "warning",
1311 title: "Project is not empty",
1312 description: "Delete all threads in this project before removing it.",
2359 void navigate({
2360 to: "/$environmentId/$threadId",
2361 params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)),
2362 });
2363 },
2364 [appSettings.sidebarThreadSortOrder, navigate, threadsByProjectKey],
2365 );
2366
2367 const addProjectFromPath = useCallback(
2368 async (rawCwd: string) => {
2369 const cwd = rawCwd.trim();
2370 if (!cwd || isAddingProject) return;
2371 const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined;
2372 if (!api) return;
2373
2374 setIsAddingProject(true);
2375 const finishAddingProject = () => {
2376 setIsAddingProject(false);
2377 setNewCwd("");
2378 setAddProjectError(null);
2379 setAddingProject(false);
2380 };
2381
2382 const existing = projects.find((project) => project.cwd === cwd);
2383 if (existing) {
2384 focusMostRecentThreadForProject({
2385 environmentId: existing.environmentId,
2386 projectId: existing.id,
13132387 });
2388 finishAddingProject();
13142389 return;
13152390 }
13162391
1317 const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`);
1318 if (!confirmed) return;
1319
2392 const projectId = newProjectId();
2393 const createdAt = new Date().toISOString();
2394 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
13202395 try {
1321 const projectDraftThread = getDraftThreadByProjectId(projectId);
1322 if (projectDraftThread) {
1323 clearComposerDraftForThread(projectDraftThread.threadId);
1324 }
1325 clearProjectDraftThreadId(projectId);
13262396 await api.orchestration.dispatchCommand({
1327 type: "project.delete",
2397 type: "project.create",
13282398 commandId: newCommandId(),
13292399 projectId,
2400 title,
2401 workspaceRoot: cwd,
2402 defaultModelSelection: {
2403 provider: "codex",
2404 model: DEFAULT_MODEL_BY_PROVIDER.codex,
2405 },
2406 createdAt,
13302407 });
2408 if (activeEnvironmentId !== null) {
2409 await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), {
2410 envMode: appSettings.defaultThreadEnvMode,
2411 }).catch(() => undefined);
2412 }
13312413 } catch (error) {
1332 const message = error instanceof Error ? error.message : "Unknown error removing project.";
1333 console.error("Failed to remove project", { projectId, error });
1334 toastManager.add({
1335 type: "error",
1336 title: `Failed to remove "${project.name}"`,
1337 description: message,
1338 });
2414 const description =
2415 error instanceof Error ? error.message : "An error occurred while adding the project.";
2416 setIsAddingProject(false);
2417 if (shouldBrowseForProjectImmediately) {
2418 toastManager.add({
2419 type: "error",
2420 title: "Failed to add project",
2421 description,
2422 });
2423 } else {
2424 setAddProjectError(description);
2425 }
2426 return;
13392427 }
2428 finishAddingProject();
13402429 },
13412430 [
1342 clearComposerDraftForThread,
1343 clearProjectDraftThreadId,
1344 copyPathToClipboard,
1345 getDraftThreadByProjectId,
2431 focusMostRecentThreadForProject,
2432 activeEnvironmentId,
2433 handleNewThread,
2434 isAddingProject,
13462435 projects,
1347 threadIdsByProjectId,
2436 shouldBrowseForProjectImmediately,
2437 appSettings.defaultThreadEnvMode,
13482438 ],
13492439 );
13502440
2441 const handleAddProject = () => {
2442 void addProjectFromPath(newCwd);
2443 };
2444
2445 const canAddProject = newCwd.trim().length > 0 && !isAddingProject;
2446
2447 const handlePickFolder = async () => {
2448 const api = readLocalApi();
2449 if (!api || isPickingFolder) return;
2450 setIsPickingFolder(true);
2451 let pickedPath: string | null = null;
2452 try {
2453 pickedPath = await api.dialogs.pickFolder();
2454 } catch {
2455 // Ignore picker failures and leave the current thread selection unchanged.
2456 }
2457 if (pickedPath) {
2458 await addProjectFromPath(pickedPath);
2459 } else if (!shouldBrowseForProjectImmediately) {
2460 addProjectInputRef.current?.focus();
2461 }
2462 setIsPickingFolder(false);
2463 };
2464
2465 const handleStartAddProject = () => {
2466 setAddProjectError(null);
2467 if (shouldBrowseForProjectImmediately) {
2468 void handlePickFolder();
2469 return;
2470 }
2471 setAddingProject((prev) => !prev);
2472 };
2473
2474 const navigateToThread = useCallback(
2475 (threadRef: ScopedThreadRef) => {
2476 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
2477 clearSelection();
2478 }
2479 setSelectionAnchor(scopedThreadKey(threadRef));
2480 void navigate({
2481 to: "/$environmentId/$threadId",
2482 params: buildThreadRouteParams(threadRef),
2483 });
2484 },
2485 [clearSelection, navigate, setSelectionAnchor],
2486 );
2487
13512488 const projectDnDSensors = useSensors(
13522489 useSensor(PointerSensor, {
13532490 activationConstraint: { distance: 6 },
......@@ -1371,10 +2508,10 @@ export default function Sidebar() {
13712508 dragInProgressRef.current = false;
13722509 const { active, over } = event;
13732510 if (!over || active.id === over.id) return;
1374 const activeProject = sidebarProjects.find((project) => project.id === active.id);
1375 const overProject = sidebarProjects.find((project) => project.id === over.id);
2511 const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);
2512 const overProject = sidebarProjects.find((project) => project.projectKey === over.id);
13762513 if (!activeProject || !overProject) return;
1377 reorderProjects(activeProject.id, overProject.id);
2514 reorderProjects(activeProject.projectKey, overProject.projectKey);
13782515 },
13792516 [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects],
13802517 );
......@@ -1412,148 +2549,117 @@ export default function Sidebar() {
14122549 animatedThreadListsRef.current.add(node);
14132550 }, []);
14142551
1415 const handleProjectTitlePointerDownCapture = useCallback(
1416 (event: PointerEvent<HTMLButtonElement>) => {
1417 suppressProjectClickForContextMenuRef.current = false;
1418 if (
1419 isContextMenuPointerDown({
1420 button: event.button,
1421 ctrlKey: event.ctrlKey,
1422 isMac: isMacPlatform(navigator.platform),
1423 })
1424 ) {
1425 // Keep context-menu gestures from arming the sortable drag sensor.
1426 event.stopPropagation();
1427 }
1428
1429 suppressProjectClickAfterDragRef.current = false;
1430 },
1431 [],
1432 );
1433
14342552 const visibleThreads = useMemo(
14352553 () => sidebarThreads.filter((thread) => thread.archivedAt === null),
14362554 [sidebarThreads],
14372555 );
1438 const sortedProjects = useMemo(
1439 () =>
1440 sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder),
1441 [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads],
1442 );
2556 const sortedProjects = useMemo(() => {
2557 const sortableProjects = sidebarProjects.map((project) => ({
2558 ...project,
2559 id: project.projectKey,
2560 }));
2561 const sortableThreads = visibleThreads.map((thread) => ({
2562 ...thread,
2563 projectId: scopedProjectKey(
2564 scopeProjectRef(thread.environmentId, thread.projectId),
2565 ) as ProjectId,
2566 }));
2567 return sortProjectsForSidebar(
2568 sortableProjects,
2569 sortableThreads,
2570 appSettings.sidebarProjectSortOrder,
2571 ).flatMap((project) => {
2572 const resolvedProject = sidebarProjectByKey.get(project.id);
2573 return resolvedProject ? [resolvedProject] : [];
2574 });
2575 }, [appSettings.sidebarProjectSortOrder, sidebarProjectByKey, sidebarProjects, visibleThreads]);
14432576 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";
1444 const renderedProjects = useMemo(
2577 const visibleSidebarThreadKeys = useMemo(
14452578 () =>
1446 sortedProjects.map((project) => {
1447 const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) =>
1448 resolveThreadStatusPill({
1449 thread: {
1450 ...thread,
1451 lastVisitedAt: threadLastVisitedAtById[thread.id],
1452 },
1453 });
2579 sortedProjects.flatMap((project) => {
14542580 const projectThreads = sortThreadsForSidebar(
1455 (threadIdsByProjectId[project.id] ?? [])
1456 .map((threadId) => sidebarThreadsById[threadId])
1457 .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined)
1458 .filter((thread) => thread.archivedAt === null),
2581 (threadsByProjectKey.get(project.projectKey) ?? []).filter(
2582 (thread) => thread.archivedAt === null,
2583 ),
14592584 appSettings.sidebarThreadSortOrder,
14602585 );
1461 const projectStatus = resolveProjectStatusIndicator(
1462 projectThreads.map((thread) => resolveProjectThreadStatus(thread)),
1463 );
1464 const activeThreadId = routeThreadId ?? undefined;
1465 const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
2586 const projectExpanded = projectExpandedById[project.projectKey] ?? true;
2587 const activeThreadKey = routeThreadKey ?? undefined;
14662588 const pinnedCollapsedThread =
1467 !project.expanded && activeThreadId
1468 ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null)
2589 !projectExpanded && activeThreadKey
2590 ? (projectThreads.find(
2591 (thread) =>
2592 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) ===
2593 activeThreadKey,
2594 ) ?? null)
14692595 : null;
1470 const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null;
1471 const {
1472 hasHiddenThreads,
1473 hiddenThreads,
1474 visibleThreads: visibleProjectThreads,
1475 } = getVisibleThreadsForProject({
1476 threads: projectThreads,
1477 activeThreadId,
1478 isThreadListExpanded,
1479 previewLimit: THREAD_PREVIEW_LIMIT,
1480 });
1481 const hiddenThreadStatus = resolveProjectStatusIndicator(
1482 hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)),
2596 const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null;
2597 if (!shouldShowThreadPanel) {
2598 return [];
2599 }
2600 const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey);
2601 const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;
2602 const previewThreads =
2603 isThreadListExpanded || !hasOverflowingThreads
2604 ? projectThreads
2605 : projectThreads.slice(0, THREAD_PREVIEW_LIMIT);
2606 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads;
2607 return renderedThreads.map((thread) =>
2608 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
14832609 );
1484 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
1485 const renderedThreadIds = pinnedCollapsedThread
1486 ? [pinnedCollapsedThread.id]
1487 : visibleProjectThreads.map((thread) => thread.id);
1488 const showEmptyThreadState = project.expanded && projectThreads.length === 0;
1489
1490 return {
1491 hasHiddenThreads,
1492 hiddenThreadStatus,
1493 orderedProjectThreadIds,
1494 project,
1495 projectStatus,
1496 renderedThreadIds,
1497 showEmptyThreadState,
1498 shouldShowThreadPanel,
1499 isThreadListExpanded,
1500 };
15012610 }),
15022611 [
15032612 appSettings.sidebarThreadSortOrder,
15042613 expandedThreadListsByProject,
1505 routeThreadId,
2614 projectExpandedById,
2615 routeThreadKey,
15062616 sortedProjects,
1507 sidebarThreadsById,
1508 threadIdsByProjectId,
1509 threadLastVisitedAtById,
2617 threadsByProjectKey,
15102618 ],
15112619 );
1512 const visibleSidebarThreadIds = useMemo(
1513 () => getVisibleSidebarThreadIds(renderedProjects),
1514 [renderedProjects],
1515 );
1516 const threadJumpCommandById = useMemo(() => {
1517 const mapping = new Map<ThreadId, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>();
1518 for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) {
2620 const threadJumpCommandByKey = useMemo(() => {
2621 const mapping = new Map<string, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>();
2622 for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) {
15192623 const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex);
15202624 if (!jumpCommand) {
15212625 return mapping;
15222626 }
1523 mapping.set(threadId, jumpCommand);
2627 mapping.set(threadKey, jumpCommand);
15242628 }
15252629
15262630 return mapping;
1527 }, [visibleSidebarThreadIds]);
1528 const threadJumpThreadIds = useMemo(
1529 () => [...threadJumpCommandById.keys()],
1530 [threadJumpCommandById],
1531 );
1532 const threadJumpLabelById = useMemo(() => {
1533 const mapping = new Map<ThreadId, string>();
1534 for (const [threadId, command] of threadJumpCommandById) {
1535 const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions);
1536 if (label) {
1537 mapping.set(threadId, label);
1538 }
1539 }
1540 return mapping;
1541 }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]);
1542 const orderedSidebarThreadIds = visibleSidebarThreadIds;
2631 }, [visibleSidebarThreadKeys]);
2632 const threadJumpThreadKeys = useMemo(
2633 () => [...threadJumpCommandByKey.keys()],
2634 [threadJumpCommandByKey],
2635 );
2636 const [threadJumpLabelByKey, setThreadJumpLabelByKey] =
2637 useState<ReadonlyMap<string, string>>(EMPTY_THREAD_JUMP_LABELS);
2638 const visibleThreadJumpLabelByKey = showThreadJumpHints
2639 ? threadJumpLabelByKey
2640 : EMPTY_THREAD_JUMP_LABELS;
2641 const orderedSidebarThreadKeys = visibleSidebarThreadKeys;
15432642
15442643 useEffect(() => {
1545 const getShortcutContext = () => ({
1546 terminalFocus: isTerminalFocused(),
1547 terminalOpen: routeTerminalOpen,
1548 });
1549
15502644 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
1551 updateThreadJumpHintsVisibility(
1552 shouldShowThreadJumpHints(event, keybindings, {
2645 const shortcutContext = getCurrentSidebarShortcutContext();
2646 const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, {
2647 platform,
2648 context: shortcutContext,
2649 });
2650 setThreadJumpLabelByKey((current) => {
2651 if (!shouldShowHints) {
2652 return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS;
2653 }
2654 const nextLabelMap = buildThreadJumpLabelMap({
2655 keybindings,
15532656 platform,
1554 context: getShortcutContext(),
1555 }),
1556 );
2657 terminalOpen: shortcutContext.terminalOpen,
2658 threadJumpCommandByKey,
2659 });
2660 return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap;
2661 });
2662 updateThreadJumpHintsVisibility(shouldShowHints);
15572663
15582664 if (event.defaultPrevented || event.repeat) {
15592665 return;
......@@ -1561,22 +2667,26 @@ export default function Sidebar() {
15612667
15622668 const command = resolveShortcutCommand(event, keybindings, {
15632669 platform,
1564 context: getShortcutContext(),
2670 context: shortcutContext,
15652671 });
15662672 const traversalDirection = threadTraversalDirectionFromCommand(command);
15672673 if (traversalDirection !== null) {
1568 const targetThreadId = resolveAdjacentThreadId({
1569 threadIds: orderedSidebarThreadIds,
1570 currentThreadId: routeThreadId,
2674 const targetThreadKey = resolveAdjacentThreadId({
2675 threadIds: orderedSidebarThreadKeys,
2676 currentThreadId: routeThreadKey,
15712677 direction: traversalDirection,
15722678 });
1573 if (!targetThreadId) {
2679 if (!targetThreadKey) {
2680 return;
2681 }
2682 const targetThread = sidebarThreadByKey.get(targetThreadKey);
2683 if (!targetThread) {
15742684 return;
15752685 }
15762686
15772687 event.preventDefault();
15782688 event.stopPropagation();
1579 navigateToThread(targetThreadId);
2689 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
15802690 return;
15812691 }
15822692
......@@ -1585,26 +2695,45 @@ export default function Sidebar() {
15852695 return;
15862696 }
15872697
1588 const targetThreadId = threadJumpThreadIds[jumpIndex];
1589 if (!targetThreadId) {
2698 const targetThreadKey = threadJumpThreadKeys[jumpIndex];
2699 if (!targetThreadKey) {
2700 return;
2701 }
2702 const targetThread = sidebarThreadByKey.get(targetThreadKey);
2703 if (!targetThread) {
15902704 return;
15912705 }
15922706
15932707 event.preventDefault();
15942708 event.stopPropagation();
1595 navigateToThread(targetThreadId);
2709 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
15962710 };
15972711
15982712 const onWindowKeyUp = (event: globalThis.KeyboardEvent) => {
1599 updateThreadJumpHintsVisibility(
1600 shouldShowThreadJumpHints(event, keybindings, {
2713 const shortcutContext = getCurrentSidebarShortcutContext();
2714 const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, {
2715 platform,
2716 context: shortcutContext,
2717 });
2718 setThreadJumpLabelByKey((current) => {
2719 if (!shouldShowHints) {
2720 return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS;
2721 }
2722 const nextLabelMap = buildThreadJumpLabelMap({
2723 keybindings,
16012724 platform,
1602 context: getShortcutContext(),
1603 }),
1604 );
2725 terminalOpen: shortcutContext.terminalOpen,
2726 threadJumpCommandByKey,
2727 });
2728 return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap;
2729 });
2730 updateThreadJumpHintsVisibility(shouldShowHints);
16052731 };
16062732
16072733 const onWindowBlur = () => {
2734 setThreadJumpLabelByKey((current) =>
2735 current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS,
2736 );
16082737 updateThreadJumpHintsVisibility(false);
16092738 };
16102739
......@@ -1618,301 +2747,21 @@ export default function Sidebar() {
16182747 window.removeEventListener("blur", onWindowBlur);
16192748 };
16202749 }, [
2750 getCurrentSidebarShortcutContext,
16212751 keybindings,
16222752 navigateToThread,
1623 orderedSidebarThreadIds,
2753 orderedSidebarThreadKeys,
16242754 platform,
1625 routeTerminalOpen,
1626 routeThreadId,
1627 threadJumpThreadIds,
2755 routeThreadKey,
2756 sidebarThreadByKey,
2757 threadJumpCommandByKey,
2758 threadJumpThreadKeys,
16282759 updateThreadJumpHintsVisibility,
16292760 ]);
16302761
1631 function renderProjectItem(
1632 renderedProject: (typeof renderedProjects)[number],
1633 dragHandleProps: SortableProjectHandleProps | null,
1634 ) {
1635 const {
1636 hasHiddenThreads,
1637 hiddenThreadStatus,
1638 orderedProjectThreadIds,
1639 project,
1640 projectStatus,
1641 renderedThreadIds,
1642 showEmptyThreadState,
1643 shouldShowThreadPanel,
1644 isThreadListExpanded,
1645 } = renderedProject;
1646 return (
1647 <>
1648 <div className="group/project-header relative">
1649 <SidebarMenuButton
1650 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
1651 size="sm"
1652 className={`gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${
1653 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
1654 }`}
1655 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
1656 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
1657 onPointerDownCapture={handleProjectTitlePointerDownCapture}
1658 onClick={(event) => handleProjectTitleClick(event, project.id)}
1659 onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}
1660 onContextMenu={(event) => {
1661 event.preventDefault();
1662 suppressProjectClickForContextMenuRef.current = true;
1663 void handleProjectContextMenu(project.id, {
1664 x: event.clientX,
1665 y: event.clientY,
1666 });
1667 }}
1668 >
1669 {!project.expanded && projectStatus ? (
1670 <span
1671 aria-hidden="true"
1672 title={projectStatus.label}
1673 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
1674 >
1675 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
1676 <span
1677 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
1678 projectStatus.pulse ? "animate-pulse" : ""
1679 }`}
1680 />
1681 </span>
1682 <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
1683 </span>
1684 ) : (
1685 <ChevronRightIcon
1686 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1687 project.expanded ? "rotate-90" : ""
1688 }`}
1689 />
1690 )}
1691 <ProjectFavicon cwd={project.cwd} />
1692 {renamingProjectId === project.id ? (
1693 <input
1694 ref={(element) => {
1695 if (element && projectRenamingInputRef.current !== element) {
1696 projectRenamingInputRef.current = element;
1697 element.focus();
1698 element.select();
1699 }
1700 }}
1701 className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-xs font-medium text-foreground/90 outline-none"
1702 value={renamingProjectTitle}
1703 onChange={(event) => setRenamingProjectTitle(event.target.value)}
1704 onKeyDown={(event) => {
1705 event.stopPropagation();
1706 if (event.key === "Enter") {
1707 event.preventDefault();
1708 projectRenamingCommittedRef.current = true;
1709 void commitProjectRename(project.id, renamingProjectTitle, project.name);
1710 } else if (event.key === "Escape") {
1711 event.preventDefault();
1712 projectRenamingCommittedRef.current = true;
1713 cancelProjectRename();
1714 }
1715 }}
1716 onBlur={() => {
1717 if (!projectRenamingCommittedRef.current) {
1718 void commitProjectRename(project.id, renamingProjectTitle, project.name);
1719 }
1720 }}
1721 onClick={(event) => event.stopPropagation()}
1722 onPointerDown={(event) => event.stopPropagation()}
1723 />
1724 ) : (
1725 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1726 {project.name}
1727 </span>
1728 )}
1729 </SidebarMenuButton>
1730 <Tooltip>
1731 <TooltipTrigger
1732 render={
1733 <SidebarMenuAction
1734 render={
1735 <button
1736 type="button"
1737 aria-label={`Create new thread in ${project.name}`}
1738 data-testid="new-thread-button"
1739 />
1740 }
1741 showOnHover
1742 className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1743 onClick={(event) => {
1744 event.preventDefault();
1745 event.stopPropagation();
1746 const seedContext = resolveSidebarNewThreadSeedContext({
1747 projectId: project.id,
1748 defaultEnvMode: resolveSidebarNewThreadEnvMode({
1749 defaultEnvMode: appSettings.defaultThreadEnvMode,
1750 }),
1751 activeThread:
1752 activeThread && activeThread.projectId === project.id
1753 ? {
1754 projectId: activeThread.projectId,
1755 branch: activeThread.branch,
1756 worktreePath: activeThread.worktreePath,
1757 }
1758 : null,
1759 activeDraftThread:
1760 activeDraftThread && activeDraftThread.projectId === project.id
1761 ? {
1762 projectId: activeDraftThread.projectId,
1763 branch: activeDraftThread.branch,
1764 worktreePath: activeDraftThread.worktreePath,
1765 envMode: activeDraftThread.envMode,
1766 }
1767 : null,
1768 });
1769 void handleNewThread(project.id, {
1770 ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}),
1771 ...(seedContext.worktreePath !== undefined
1772 ? { worktreePath: seedContext.worktreePath }
1773 : {}),
1774 envMode: seedContext.envMode,
1775 });
1776 }}
1777 >
1778 <SquarePenIcon className="size-3.5" />
1779 </SidebarMenuAction>
1780 }
1781 />
1782 <TooltipPopup side="top">
1783 {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
1784 </TooltipPopup>
1785 </Tooltip>
1786 </div>
1787
1788 <SidebarMenuSub
1789 ref={attachThreadListAutoAnimateRef}
1790 className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0"
1791 >
1792 {shouldShowThreadPanel && showEmptyThreadState ? (
1793 <SidebarMenuSubItem className="w-full" data-thread-selection-safe>
1794 <div
1795 data-thread-selection-safe
1796 className="flex h-6 w-full translate-x-0 items-center px-2 text-left text-[10px] text-muted-foreground/60"
1797 >
1798 <span>No threads yet</span>
1799 </div>
1800 </SidebarMenuSubItem>
1801 ) : null}
1802 {shouldShowThreadPanel &&
1803 renderedThreadIds.map((threadId) => (
1804 <SidebarThreadRow
1805 key={threadId}
1806 threadId={threadId}
1807 projectCwd={project.cwd}
1808 orderedProjectThreadIds={orderedProjectThreadIds}
1809 routeThreadId={routeThreadId}
1810 selectedThreadIds={selectedThreadIds}
1811 showThreadJumpHints={showThreadJumpHints}
1812 jumpLabel={threadJumpLabelById.get(threadId) ?? null}
1813 appSettingsConfirmThreadArchive={appSettings.confirmThreadArchive}
1814 renamingThreadId={renamingThreadId}
1815 renamingTitle={renamingTitle}
1816 setRenamingTitle={setRenamingTitle}
1817 onRenamingInputMount={handleRenamingInputMount}
1818 hasRenameCommitted={hasRenameCommitted}
1819 markRenameCommitted={markRenameCommitted}
1820 confirmingArchiveThreadId={confirmingArchiveThreadId}
1821 setConfirmingArchiveThreadId={setConfirmingArchiveThreadId}
1822 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
1823 handleThreadClick={handleThreadClick}
1824 navigateToThread={navigateToThread}
1825 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
1826 handleThreadContextMenu={handleThreadContextMenu}
1827 clearSelection={clearSelection}
1828 commitRename={commitRename}
1829 cancelRename={cancelRename}
1830 attemptArchiveThread={attemptArchiveThread}
1831 openPrLink={openPrLink}
1832 />
1833 ))}
1834
1835 {project.expanded && hasHiddenThreads && !isThreadListExpanded && (
1836 <SidebarMenuSubItem className="w-full">
1837 <SidebarMenuSubButton
1838 render={<button type="button" />}
1839 data-thread-selection-safe
1840 size="sm"
1841 className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1842 onClick={() => {
1843 expandThreadListForProject(project.id);
1844 }}
1845 >
1846 <span className="flex min-w-0 flex-1 items-center gap-2">
1847 {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />}
1848 <span>Show more</span>
1849 </span>
1850 </SidebarMenuSubButton>
1851 </SidebarMenuSubItem>
1852 )}
1853 {project.expanded && hasHiddenThreads && isThreadListExpanded && (
1854 <SidebarMenuSubItem className="w-full">
1855 <SidebarMenuSubButton
1856 render={<button type="button" />}
1857 data-thread-selection-safe
1858 size="sm"
1859 className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1860 onClick={() => {
1861 collapseThreadListForProject(project.id);
1862 }}
1863 >
1864 <span>Show less</span>
1865 </SidebarMenuSubButton>
1866 </SidebarMenuSubItem>
1867 )}
1868 </SidebarMenuSub>
1869 </>
1870 );
1871 }
1872
1873 const handleProjectTitleClick = useCallback(
1874 (event: MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
1875 if (suppressProjectClickForContextMenuRef.current) {
1876 suppressProjectClickForContextMenuRef.current = false;
1877 event.preventDefault();
1878 event.stopPropagation();
1879 return;
1880 }
1881 if (dragInProgressRef.current) {
1882 event.preventDefault();
1883 event.stopPropagation();
1884 return;
1885 }
1886 if (suppressProjectClickAfterDragRef.current) {
1887 // Consume the synthetic click emitted after a drag release.
1888 suppressProjectClickAfterDragRef.current = false;
1889 event.preventDefault();
1890 event.stopPropagation();
1891 return;
1892 }
1893 if (selectedThreadIds.size > 0) {
1894 clearSelection();
1895 }
1896 toggleProject(projectId);
1897 },
1898 [clearSelection, selectedThreadIds.size, toggleProject],
1899 );
1900
1901 const handleProjectTitleKeyDown = useCallback(
1902 (event: KeyboardEvent<HTMLButtonElement>, projectId: ProjectId) => {
1903 if (event.key !== "Enter" && event.key !== " ") return;
1904 event.preventDefault();
1905 if (dragInProgressRef.current) {
1906 return;
1907 }
1908 toggleProject(projectId);
1909 },
1910 [toggleProject],
1911 );
1912
19132762 useEffect(() => {
19142763 const onMouseDown = (event: globalThis.MouseEvent) => {
1915 if (selectedThreadIds.size === 0) return;
2764 if (selectedThreadCount === 0) return;
19162765 const target = event.target instanceof HTMLElement ? event.target : null;
19172766 if (!shouldClearThreadSelectionOnMouseDown(target)) return;
19182767 clearSelection();
......@@ -1922,7 +2771,7 @@ export default function Sidebar() {
19222771 return () => {
19232772 window.removeEventListener("mousedown", onMouseDown);
19242773 };
1925 }, [clearSelection, selectedThreadIds.size]);
2774 }, [clearSelection, selectedThreadCount]);
19262775
19272776 useEffect(() => {
19282777 if (!isElectron) return;
......@@ -1967,10 +2816,6 @@ export default function Sidebar() {
19672816 desktopUpdateState && showArm64IntelBuildWarning
19682817 ? getArm64IntelBuildWarningDescription(desktopUpdateState)
19692818 : null;
1970 const newThreadShortcutLabel =
1971 shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ??
1972 shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions);
1973
19742819 const handleDesktopUpdateButtonClick = useCallback(() => {
19752820 const bridge = window.desktopBridge;
19762821 if (!bridge || !desktopUpdateState) return;
......@@ -2033,246 +2878,82 @@ export default function Sidebar() {
20332878 }
20342879 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);
20352880
2036 const expandThreadListForProject = useCallback((projectId: ProjectId) => {
2881 const expandThreadListForProject = useCallback((projectKey: string) => {
20372882 setExpandedThreadListsByProject((current) => {
2038 if (current.has(projectId)) return current;
2883 if (current.has(projectKey)) return current;
20392884 const next = new Set(current);
2040 next.add(projectId);
2885 next.add(projectKey);
20412886 return next;
20422887 });
20432888 }, []);
20442889
2045 const collapseThreadListForProject = useCallback((projectId: ProjectId) => {
2890 const collapseThreadListForProject = useCallback((projectKey: string) => {
20462891 setExpandedThreadListsByProject((current) => {
2047 if (!current.has(projectId)) return current;
2892 if (!current.has(projectKey)) return current;
20482893 const next = new Set(current);
2049 next.delete(projectId);
2894 next.delete(projectKey);
20502895 return next;
20512896 });
20522897 }, []);
20532898
2054 const wordmark = (
2055 <div className="flex items-center gap-2">
2056 <SidebarTrigger className="shrink-0 md:hidden" />
2057 <Tooltip>
2058 <TooltipTrigger
2059 render={
2060 <Link
2061 aria-label="Go to threads"
2062 className="ml-1 flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md outline-hidden ring-ring transition-colors hover:text-foreground focus-visible:ring-2"
2063 to="/"
2064 >
2065 <T3Wordmark />
2066 <span className="truncate text-sm font-medium tracking-tight text-muted-foreground">
2067 Code
2068 </span>
2069 <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground/60">
2070 {APP_STAGE_LABEL}
2071 </span>
2072 </Link>
2073 }
2074 />
2075 <TooltipPopup side="bottom" sideOffset={2}>
2076 Version {APP_VERSION}
2077 </TooltipPopup>
2078 </Tooltip>
2079 </div>
2080 );
2081
20822899 return (
20832900 <>
2084 {isElectron ? (
2085 <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
2086 {wordmark}
2087 </SidebarHeader>
2088 ) : (
2089 <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">
2090 {wordmark}
2091 </SidebarHeader>
2092 )}
2901 <SidebarChromeHeader isElectron={isElectron} />
20932902
20942903 {isOnSettings ? (
20952904 <SettingsSidebarNav pathname={pathname} />
20962905 ) : (
20972906 <>
2098 <SidebarContent className="gap-0">
2099 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
2100 <SidebarGroup className="px-2 pt-2 pb-0">
2101 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
2102 <TriangleAlertIcon />
2103 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
2104 <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
2105 {desktopUpdateButtonAction !== "none" ? (
2106 <AlertAction>
2107 <Button
2108 size="xs"
2109 variant="outline"
2110 disabled={desktopUpdateButtonDisabled}
2111 onClick={handleDesktopUpdateButtonClick}
2112 >
2113 {desktopUpdateButtonAction === "download"
2114 ? "Download ARM build"
2115 : "Install ARM build"}
2116 </Button>
2117 </AlertAction>
2118 ) : null}
2119 </Alert>
2120 </SidebarGroup>
2121 ) : null}
2122 <SidebarGroup className="px-2 py-2">
2123 <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">
2124 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
2125 Projects
2126 </span>
2127 <div className="flex items-center gap-1">
2128 <ProjectSortMenu
2129 projectSortOrder={appSettings.sidebarProjectSortOrder}
2130 threadSortOrder={appSettings.sidebarThreadSortOrder}
2131 onProjectSortOrderChange={(sortOrder) => {
2132 updateSettings({ sidebarProjectSortOrder: sortOrder });
2133 }}
2134 onThreadSortOrderChange={(sortOrder) => {
2135 updateSettings({ sidebarThreadSortOrder: sortOrder });
2136 }}
2137 />
2138 <Tooltip>
2139 <TooltipTrigger
2140 render={
2141 <button
2142 type="button"
2143 aria-label={
2144 shouldShowProjectPathEntry ? "Cancel add project" : "Add project"
2145 }
2146 aria-pressed={shouldShowProjectPathEntry}
2147 className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
2148 onClick={handleStartAddProject}
2149 />
2150 }
2151 >
2152 <PlusIcon
2153 className={`size-3.5 transition-transform duration-150 ${
2154 shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
2155 }`}
2156 />
2157 </TooltipTrigger>
2158 <TooltipPopup side="right">
2159 {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
2160 </TooltipPopup>
2161 </Tooltip>
2162 </div>
2163 </div>
2164 {shouldShowProjectPathEntry && (
2165 <div className="mb-2 px-1">
2166 {isElectron && (
2167 <button
2168 type="button"
2169 className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
2170 onClick={() => void handlePickFolder()}
2171 disabled={isPickingFolder || isAddingProject}
2172 >
2173 <FolderIcon className="size-3.5" />
2174 {isPickingFolder ? "Picking folder..." : "Browse for folder"}
2175 </button>
2176 )}
2177 <div className="flex gap-1.5">
2178 <input
2179 ref={addProjectInputRef}
2180 className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${
2181 addProjectError
2182 ? "border-red-500/70 focus:border-red-500"
2183 : "border-border focus:border-ring"
2184 }`}
2185 placeholder="/path/to/project"
2186 value={newCwd}
2187 onChange={(event) => {
2188 setNewCwd(event.target.value);
2189 setAddProjectError(null);
2190 }}
2191 onKeyDown={(event) => {
2192 if (event.key === "Enter") handleAddProject();
2193 if (event.key === "Escape") {
2194 setAddingProject(false);
2195 setAddProjectError(null);
2196 }
2197 }}
2198 autoFocus
2199 />
2200 <button
2201 type="button"
2202 className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60"
2203 onClick={handleAddProject}
2204 disabled={!canAddProject}
2205 >
2206 {isAddingProject ? "Adding..." : "Add"}
2207 </button>
2208 </div>
2209 {addProjectError && (
2210 <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400">
2211 {addProjectError}
2212 </p>
2213 )}
2214 </div>
2215 )}
2216
2217 {isManualProjectSorting ? (
2218 <DndContext
2219 sensors={projectDnDSensors}
2220 collisionDetection={projectCollisionDetection}
2221 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
2222 onDragStart={handleProjectDragStart}
2223 onDragEnd={handleProjectDragEnd}
2224 onDragCancel={handleProjectDragCancel}
2225 >
2226 <SidebarMenu>
2227 <SortableContext
2228 items={renderedProjects.map((renderedProject) => renderedProject.project.id)}
2229 strategy={verticalListSortingStrategy}
2230 >
2231 {renderedProjects.map((renderedProject) => (
2232 <SortableProjectItem
2233 key={renderedProject.project.id}
2234 projectId={renderedProject.project.id}
2235 >
2236 {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)}
2237 </SortableProjectItem>
2238 ))}
2239 </SortableContext>
2240 </SidebarMenu>
2241 </DndContext>
2242 ) : (
2243 <SidebarMenu ref={attachProjectListAutoAnimateRef}>
2244 {renderedProjects.map((renderedProject) => (
2245 <SidebarMenuItem key={renderedProject.project.id} className="rounded-md">
2246 {renderProjectItem(renderedProject, null)}
2247 </SidebarMenuItem>
2248 ))}
2249 </SidebarMenu>
2250 )}
2251
2252 {projects.length === 0 && !shouldShowProjectPathEntry && (
2253 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
2254 No projects yet
2255 </div>
2256 )}
2257 </SidebarGroup>
2258 </SidebarContent>
2907 <SidebarProjectsContent
2908 showArm64IntelBuildWarning={showArm64IntelBuildWarning}
2909 arm64IntelBuildWarningDescription={arm64IntelBuildWarningDescription}
2910 desktopUpdateButtonAction={desktopUpdateButtonAction}
2911 desktopUpdateButtonDisabled={desktopUpdateButtonDisabled}
2912 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
2913 projectSortOrder={appSettings.sidebarProjectSortOrder}
2914 threadSortOrder={appSettings.sidebarThreadSortOrder}
2915 updateSettings={updateSettings}
2916 shouldShowProjectPathEntry={shouldShowProjectPathEntry}
2917 handleStartAddProject={handleStartAddProject}
2918 isElectron={isElectron}
2919 isPickingFolder={isPickingFolder}
2920 isAddingProject={isAddingProject}
2921 handlePickFolder={handlePickFolder}
2922 addProjectInputRef={addProjectInputRef}
2923 addProjectError={addProjectError}
2924 newCwd={newCwd}
2925 setNewCwd={setNewCwd}
2926 setAddProjectError={setAddProjectError}
2927 handleAddProject={handleAddProject}
2928 setAddingProject={setAddingProject}
2929 canAddProject={canAddProject}
2930 isManualProjectSorting={isManualProjectSorting}
2931 projectDnDSensors={projectDnDSensors}
2932 projectCollisionDetection={projectCollisionDetection}
2933 handleProjectDragStart={handleProjectDragStart}
2934 handleProjectDragEnd={handleProjectDragEnd}
2935 handleProjectDragCancel={handleProjectDragCancel}
2936 handleNewThread={handleNewThread}
2937 archiveThread={archiveThread}
2938 deleteThread={deleteThread}
2939 sortedProjects={sortedProjects}
2940 expandedThreadListsByProject={expandedThreadListsByProject}
2941 activeRouteProjectKey={activeRouteProjectKey}
2942 routeThreadKey={routeThreadKey}
2943 newThreadShortcutLabel={newThreadShortcutLabel}
2944 threadJumpLabelByKey={visibleThreadJumpLabelByKey}
2945 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2946 expandThreadListForProject={expandThreadListForProject}
2947 collapseThreadListForProject={collapseThreadListForProject}
2948 dragInProgressRef={dragInProgressRef}
2949 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
2950 suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
2951 attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}
2952 projectsLength={projects.length}
2953 />
22592954
22602955 <SidebarSeparator />
2261 <SidebarFooter className="p-2">
2262 <SidebarUpdatePill />
2263 <SidebarMenu>
2264 <SidebarMenuItem>
2265 <SidebarMenuButton
2266 size="sm"
2267 className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
2268 onClick={() => void navigate({ to: "/settings" })}
2269 >
2270 <SettingsIcon className="size-3.5" />
2271 <span className="text-xs">Settings</span>
2272 </SidebarMenuButton>
2273 </SidebarMenuItem>
2274 </SidebarMenu>
2275 </SidebarFooter>
2956 <SidebarChromeFooter />
22762957 </>
22772958 )}
22782959 </>
apps/web/src/components/ThreadTerminalDrawer.browser.tsx 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 @@
11import { FitAddon } from "@xterm/addon-fit";
22import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";
33import {
4 type ScopedThreadRef,
45 type TerminalEvent,
56 type TerminalSessionSnapshot,
67 type ThreadId,
......@@ -31,7 +32,8 @@ import {
3132 MAX_TERMINALS_PER_GROUP,
3233 type ThreadTerminalGroup,
3334} from "../types";
34import { readNativeApi } from "~/nativeApi";
35import { readEnvironmentApi } from "~/environmentApi";
36import { readLocalApi } from "~/localApi";
3537import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore";
3638
3739const MIN_DRAWER_HEIGHT = 180;
......@@ -208,6 +210,7 @@ export function shouldHandleTerminalSelectionMouseUp(
208210}
209211
210212interface TerminalViewportProps {
213 threadRef: ScopedThreadRef;
211214 threadId: ThreadId;
212215 terminalId: string;
213216 terminalLabel: string;
......@@ -222,7 +225,8 @@ interface TerminalViewportProps {
222225 drawerHeight: number;
223226}
224227
225function TerminalViewport({
228export function TerminalViewport({
229 threadRef,
226230 threadId,
227231 terminalId,
228232 terminalLabel,
......@@ -260,6 +264,9 @@ function TerminalViewport({
260264 if (!mount) return;
261265
262266 let disposed = false;
267 const api = readEnvironmentApi(threadRef.environmentId);
268 const localApi = readLocalApi();
269 if (!api || !localApi) return;
263270
264271 const fitAddon = new FitAddon();
265272 const terminal = new Terminal({
......@@ -277,9 +284,6 @@ function TerminalViewport({
277284 terminalRef.current = terminal;
278285 fitAddonRef.current = fitAddon;
279286
280 const api = readNativeApi();
281 if (!api) return;
282
283287 const clearSelectionAction = () => {
284288 selectionActionRequestIdRef.current += 1;
285289 if (selectionActionTimerRef.current !== null) {
......@@ -340,7 +344,7 @@ function TerminalViewport({
340344 const requestId = ++selectionActionRequestIdRef.current;
341345 selectionActionOpenRef.current = true;
342346 try {
343 const clicked = await api.contextMenu.show(
347 const clicked = await localApi.contextMenu.show(
344348 [{ id: "add-to-chat", label: "Add to chat" }],
345349 nextAction.position,
346350 );
......@@ -416,7 +420,7 @@ function TerminalViewport({
416420 if (!latestTerminal) return;
417421
418422 if (match.kind === "url") {
419 void api.shell.openExternal(match.text).catch((error) => {
423 void localApi.shell.openExternal(match.text).catch((error: unknown) => {
420424 writeSystemMessage(
421425 latestTerminal,
422426 error instanceof Error ? error.message : "Unable to open link",
......@@ -426,7 +430,7 @@ function TerminalViewport({
426430 }
427431
428432 const target = resolvePathLinkTarget(match.text, cwd);
429 void openInPreferredEditor(api, target).catch((error) => {
433 void openInPreferredEditor(localApi, target).catch((error) => {
430434 writeSystemMessage(
431435 latestTerminal,
432436 error instanceof Error ? error.message : "Unable to open path",
......@@ -573,12 +577,12 @@ function TerminalViewport({
573577 const previousLastEntryId =
574578 selectTerminalEventEntries(
575579 previousState.terminalEventEntriesByKey,
576 threadId,
580 threadRef,
577581 terminalId,
578582 ).at(-1)?.id ?? 0;
579583 const nextEntries = selectTerminalEventEntries(
580584 state.terminalEventEntriesByKey,
581 threadId,
585 threadRef,
582586 terminalId,
583587 );
584588 const nextLastEntryId = nextEntries.at(-1)?.id ?? 0;
......@@ -608,7 +612,7 @@ function TerminalViewport({
608612 writeTerminalSnapshot(activeTerminal, snapshot);
609613 const bufferedEntries = selectTerminalEventEntries(
610614 useTerminalStateStore.getState().terminalEventEntriesByKey,
611 threadId,
615 threadRef,
612616 terminalId,
613617 );
614618 const replayEntries = selectTerminalEventEntriesAfterSnapshot(
......@@ -677,7 +681,7 @@ function TerminalViewport({
677681 // autoFocus is intentionally omitted;
678682 // it is only read at mount time and must not trigger terminal teardown/recreation.
679683 // eslint-disable-next-line react-hooks/exhaustive-deps
680 }, [cwd, runtimeEnv, terminalId, threadId]);
684 }, [cwd, runtimeEnv, terminalId, threadId, threadRef]);
681685
682686 useEffect(() => {
683687 if (!autoFocus) return;
......@@ -692,7 +696,7 @@ function TerminalViewport({
692696 }, [autoFocus, focusRequestId]);
693697
694698 useEffect(() => {
695 const api = readNativeApi();
699 const api = readEnvironmentApi(threadRef.environmentId);
696700 const terminal = terminalRef.current;
697701 const fitAddon = fitAddonRef.current;
698702 if (!api || !terminal || !fitAddon) return;
......@@ -714,13 +718,14 @@ function TerminalViewport({
714718 return () => {
715719 window.cancelAnimationFrame(frame);
716720 };
717 }, [drawerHeight, resizeEpoch, terminalId, threadId]);
721 }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]);
718722 return (
719723 <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-[4px]" />
720724 );
721725}
722726
723727interface ThreadTerminalDrawerProps {
728 threadRef: ScopedThreadRef;
724729 threadId: ThreadId;
725730 cwd: string;
726731 worktreePath?: string | null;
......@@ -773,6 +778,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA
773778}
774779
775780export default function ThreadTerminalDrawer({
781 threadRef,
776782 threadId,
777783 cwd,
778784 worktreePath,
......@@ -1098,6 +1104,7 @@ export default function ThreadTerminalDrawer({
10981104 >
10991105 <div className="h-full p-1">
11001106 <TerminalViewport
1107 threadRef={threadRef}
11011108 threadId={threadId}
11021109 terminalId={terminalId}
11031110 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
......@@ -1119,6 +1126,7 @@ export default function ThreadTerminalDrawer({
11191126 <div className="h-full p-1">
11201127 <TerminalViewport
11211128 key={resolvedActiveTerminalId}
1129 threadRef={threadRef}
11221130 threadId={threadId}
11231131 terminalId={resolvedActiveTerminalId}
11241132 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
apps/web/src/components/chat/ChatHeader.tsx+10-1
......@@ -1,9 +1,11 @@
11import {
2 type EnvironmentId,
23 type EditorId,
34 type ProjectScript,
45 type ResolvedKeybindingsConfig,
56 type ThreadId,
67} from "@t3tools/contracts";
8import { scopeThreadRef } from "@t3tools/client-runtime";
79import { memo } from "react";
810import GitActionsControl from "../GitActionsControl";
911import { DiffIcon, TerminalSquareIcon } from "lucide-react";
......@@ -15,6 +17,7 @@ import { SidebarTrigger } from "../ui/sidebar";
1517import { OpenInPicker } from "./OpenInPicker";
1618
1719interface ChatHeaderProps {
20 activeThreadEnvironmentId: EnvironmentId;
1821 activeThreadId: ThreadId;
1922 activeThreadTitle: string;
2023 activeProjectName: string | undefined;
......@@ -39,6 +42,7 @@ interface ChatHeaderProps {
3942}
4043
4144export const ChatHeader = memo(function ChatHeader({
45 activeThreadEnvironmentId,
4246 activeThreadId,
4347 activeThreadTitle,
4448 activeProjectName,
......@@ -101,7 +105,12 @@ export const ChatHeader = memo(function ChatHeader({
101105 openInCwd={openInCwd}
102106 />
103107 )}
104 {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}
108 {activeProjectName && (
109 <GitActionsControl
110 gitCwd={gitCwd}
111 activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)}
112 />
113 )}
105114 <Tooltip>
106115 <TooltipTrigger
107116 render={
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+35-27
......@@ -1,4 +1,10 @@
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";
28import "../../index.css";
39
410import { page } from "vitest/browser";
......@@ -9,35 +15,37 @@ import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
915import { TraitsMenuContent } from "./TraitsPicker";
1016import { useComposerDraftStore } from "../../composerDraftStore";
1117
18const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
19
1220async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) {
1321 const threadId = ThreadId.makeUnsafe("thread-compact-menu");
22 const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);
23 const threadKey = scopedThreadKey(threadRef);
1424 const provider = props?.modelSelection?.provider ?? "claudeAgent";
15 const draftsByThreadId = {} as ReturnType<
16 typeof useComposerDraftStore.getState
17 >["draftsByThreadId"];
1825 const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider];
1926
20 draftsByThreadId[threadId] = {
21 prompt: props?.prompt ?? "",
22 images: [],
23 nonPersistedImageIds: [],
24 persistedAttachments: [],
25 terminalContexts: [],
26 modelSelectionByProvider: {
27 [provider]: {
28 provider,
29 model,
30 ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}),
27 useComposerDraftStore.setState({
28 draftsByThreadKey: {
29 [threadKey]: {
30 prompt: props?.prompt ?? "",
31 images: [],
32 nonPersistedImageIds: [],
33 persistedAttachments: [],
34 terminalContexts: [],
35 modelSelectionByProvider: {
36 [provider]: {
37 provider,
38 model,
39 ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}),
40 },
41 },
42 activeProvider: provider,
43 runtimeMode: null,
44 interactionMode: null,
3145 },
3246 },
33 activeProvider: provider,
34 runtimeMode: null,
35 interactionMode: null,
36 };
37 useComposerDraftStore.setState({
38 draftsByThreadId,
39 draftThreadsByThreadId: {},
40 projectDraftThreadIdByProjectId: {},
47 draftThreadsByThreadKey: {},
48 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
4149 });
4250 const host = document.createElement("div");
4351 document.body.append(host);
......@@ -121,7 +129,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
121129 <TraitsMenuContent
122130 provider={provider}
123131 models={models}
124 threadId={threadId}
132 threadRef={threadRef}
125133 model={model}
126134 prompt={props?.prompt ?? ""}
127135 modelOptions={providerOptions}
......@@ -150,9 +158,9 @@ describe("CompactComposerControlsMenu", () => {
150158 afterEach(() => {
151159 document.body.innerHTML = "";
152160 useComposerDraftStore.setState({
153 draftsByThreadId: {},
154 draftThreadsByThreadId: {},
155 projectDraftThreadIdByProjectId: {},
161 draftsByThreadKey: {},
162 draftThreadsByThreadKey: {},
163 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
156164 stickyModelSelectionByProvider: {},
157165 });
158166 });
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+20-18
......@@ -1,5 +1,5 @@
11import { type ApprovalRequestId } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useRef } from "react";
2import { memo, useEffect, useEffectEvent, useRef } from "react";
33import { type PendingUserInput } from "../../session-logic";
44import {
55 derivePendingUserInputProgress,
......@@ -60,6 +60,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
6060 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
6161 const activeQuestion = progress.activeQuestion;
6262 const autoAdvanceTimerRef = useRef<number | null>(null);
63 const onAdvanceRef = useRef(onAdvance);
64
65 useEffect(() => {
66 onAdvanceRef.current = onAdvance;
67 }, [onAdvance]);
6368
6469 // Clear auto-advance timer on unmount
6570 useEffect(() => {
......@@ -70,22 +75,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
7075 };
7176 }, []);
7277
73 const handleOptionSelection = useCallback(
74 (questionId: string, optionLabel: string) => {
75 onToggleOption(questionId, optionLabel);
76 if (activeQuestion?.multiSelect) {
77 return;
78 }
79 if (autoAdvanceTimerRef.current !== null) {
80 window.clearTimeout(autoAdvanceTimerRef.current);
81 }
82 autoAdvanceTimerRef.current = window.setTimeout(() => {
83 autoAdvanceTimerRef.current = null;
84 onAdvance();
85 }, 200);
86 },
87 [activeQuestion?.multiSelect, onAdvance, onToggleOption],
88 );
78 const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => {
79 onToggleOption(questionId, optionLabel);
80 if (activeQuestion?.multiSelect) {
81 return;
82 }
83 if (autoAdvanceTimerRef.current !== null) {
84 window.clearTimeout(autoAdvanceTimerRef.current);
85 }
86 autoAdvanceTimerRef.current = window.setTimeout(() => {
87 autoAdvanceTimerRef.current = null;
88 onAdvanceRef.current();
89 }, 200);
90 });
8991
9092 // Keyboard shortcut: number keys 1-9 select corresponding options when focus is
9193 // outside editable fields. Multi-select prompts toggle options in place; single-
......@@ -112,7 +114,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
112114 };
113115 document.addEventListener("keydown", handler);
114116 return () => document.removeEventListener("keydown", handler);
115 }, [activeQuestion, handleOptionSelection, isResponding]);
117 }, [activeQuestion, isResponding]);
116118
117119 if (!activeQuestion) {
118120 return null;
apps/web/src/components/chat/MessagesTimeline.test.tsx+4
......@@ -42,6 +42,8 @@ beforeAll(() => {
4242 });
4343});
4444
45const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never;
46
4547describe("MessagesTimeline", () => {
4648 it("renders inline terminal labels with the composer chip UI", async () => {
4749 const { MessagesTimeline } = await import("./MessagesTimeline");
......@@ -85,6 +87,7 @@ describe("MessagesTimeline", () => {
8587 onRevertUserMessage={() => {}}
8688 isRevertingCheckpoint={false}
8789 onImageExpand={() => {}}
90 activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
8891 markdownCwd={undefined}
8992 resolvedTheme="light"
9093 timestampFormat="locale"
......@@ -130,6 +133,7 @@ describe("MessagesTimeline", () => {
130133 onRevertUserMessage={() => {}}
131134 isRevertingCheckpoint={false}
132135 onImageExpand={() => {}}
136 activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
133137 markdownCwd={undefined}
134138 resolvedTheme="light"
135139 timestampFormat="locale"
apps/web/src/components/chat/MessagesTimeline.tsx+4-1
......@@ -1,4 +1,4 @@
1import { type MessageId, type TurnId } from "@t3tools/contracts";
1import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts";
22import {
33 memo,
44 useCallback,
......@@ -82,6 +82,7 @@ interface MessagesTimelineProps {
8282 onRevertUserMessage: (messageId: MessageId) => void;
8383 isRevertingCheckpoint: boolean;
8484 onImageExpand: (preview: ExpandedImagePreview) => void;
85 activeThreadEnvironmentId: EnvironmentId;
8586 markdownCwd: string | undefined;
8687 resolvedTheme: "light" | "dark";
8788 timestampFormat: TimestampFormat;
......@@ -117,6 +118,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
117118 onRevertUserMessage,
118119 isRevertingCheckpoint,
119120 onImageExpand,
121 activeThreadEnvironmentId,
120122 markdownCwd,
121123 resolvedTheme,
122124 timestampFormat,
......@@ -531,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
531533 <div className="min-w-0 px-1 py-0.5">
532534 <ProposedPlanCard
533535 planMarkdown={row.proposedPlan.planMarkdown}
536 environmentId={activeThreadEnvironmentId}
534537 cwd={markdownCwd}
535538 workspaceRoot={workspaceRoot}
536539 />
apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx+23-6
......@@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = {
1919 height: 1_100,
2020};
2121const MARKDOWN_CWD = "/repo/project";
22const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never;
2223
2324interface RowMeasurement {
2425 actualHeightPx: number;
......@@ -31,7 +32,10 @@ interface RowMeasurement {
3132interface VirtualizationScenario {
3233 name: string;
3334 targetRowId: string;
34 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;
35 props: Omit<
36 ComponentProps<typeof MessagesTimeline>,
37 "scrollContainer" | "activeThreadEnvironmentId"
38 >;
3539 maxEstimateDeltaPx: number;
3640}
3741
......@@ -48,7 +52,10 @@ interface VirtualizerSnapshot {
4852}
4953
5054function MessagesTimelineBrowserHarness(
51 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">,
55 props: Omit<
56 ComponentProps<typeof MessagesTimeline>,
57 "scrollContainer" | "activeThreadEnvironmentId"
58 >,
5259) {
5360 const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null);
5461 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>(
......@@ -73,6 +80,7 @@ function MessagesTimelineBrowserHarness(
7380 >
7481 <MessagesTimeline
7582 {...props}
83 activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
7684 scrollContainer={scrollContainer}
7785 expandedWorkGroups={expandedWorkGroups}
7886 onToggleWorkGroup={handleToggleWorkGroup}
......@@ -143,7 +151,7 @@ function createBaseTimelineProps(input: {
143151 completionDividerBeforeEntryId?: string | null;
144152 turnDiffSummaryByAssistantMessageId?: Map<MessageId, TurnDiffSummary>;
145153 onVirtualizerSnapshot?: ComponentProps<typeof MessagesTimeline>["onVirtualizerSnapshot"];
146}): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer"> {
154}): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer" | "activeThreadEnvironmentId"> {
147155 return {
148156 hasMessages: true,
149157 isWorking: false,
......@@ -481,7 +489,10 @@ async function waitForElement<T extends Element>(
481489
482490async function measureTimelineRow(input: {
483491 host: HTMLElement;
484 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;
492 props: Omit<
493 ComponentProps<typeof MessagesTimeline>,
494 "scrollContainer" | "activeThreadEnvironmentId"
495 >;
485496 targetRowId: string;
486497}): Promise<RowMeasurement> {
487498 const scrollContainer = await waitForElement(
......@@ -550,7 +561,10 @@ async function measureTimelineRow(input: {
550561}
551562
552563async function mountMessagesTimeline(input: {
553 props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">;
564 props: Omit<
565 ComponentProps<typeof MessagesTimeline>,
566 "scrollContainer" | "activeThreadEnvironmentId"
567 >;
554568 viewport?: { width: number; height: number };
555569}) {
556570 const viewport = input.viewport ?? DEFAULT_VIEWPORT;
......@@ -576,7 +590,10 @@ async function mountMessagesTimeline(input: {
576590 return {
577591 host,
578592 rerender: async (
579 nextProps: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">,
593 nextProps: Omit<
594 ComponentProps<typeof MessagesTimeline>,
595 "scrollContainer" | "activeThreadEnvironmentId"
596 >,
580597 ) => {
581598 await screen.rerender(<MessagesTimelineBrowserHarness {...nextProps} />);
582599 await waitForLayout();
apps/web/src/components/chat/OpenInPicker.tsx+3-3
......@@ -18,7 +18,7 @@ import {
1818 Zed,
1919} from "../Icons";
2020import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";
21import { readNativeApi } from "~/nativeApi";
21import { readLocalApi } from "~/localApi";
2222
2323const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => {
2424 const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [
......@@ -93,7 +93,7 @@ export const OpenInPicker = memo(function OpenInPicker({
9393
9494 const openInEditor = useCallback(
9595 (editorId: EditorId | null) => {
96 const api = readNativeApi();
96 const api = readLocalApi();
9797 if (!api || !openInCwd) return;
9898 const editor = editorId ?? preferredEditor;
9999 if (!editor) return;
......@@ -110,7 +110,7 @@ export const OpenInPicker = memo(function OpenInPicker({
110110
111111 useEffect(() => {
112112 const handler = (e: globalThis.KeyboardEvent) => {
113 const api = readNativeApi();
113 const api = readLocalApi();
114114 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
115115 if (!api || !openInCwd) return;
116116 if (!preferredEditor) return;
apps/web/src/components/chat/ProposedPlanCard.tsx+5-2
......@@ -1,4 +1,5 @@
11import { memo, useState, useId } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";
23import {
34 buildCollapsedProposedPlanPreviewMarkdown,
45 buildProposedPlanMarkdownFilename,
......@@ -24,15 +25,17 @@ import {
2425 DialogTitle,
2526} from "../ui/dialog";
2627import { toastManager } from "../ui/toast";
27import { readNativeApi } from "~/nativeApi";
28import { readEnvironmentApi } from "~/environmentApi";
2829import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
2930
3031export const ProposedPlanCard = memo(function ProposedPlanCard({
3132 planMarkdown,
33 environmentId,
3234 cwd,
3335 workspaceRoot,
3436}: {
3537 planMarkdown: string;
38 environmentId: EnvironmentId;
3639 cwd: string | undefined;
3740 workspaceRoot: string | undefined;
3841}) {
......@@ -82,7 +85,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
8285 };
8386
8487 const handleSaveToWorkspace = () => {
85 const api = readNativeApi();
88 const api = readEnvironmentApi(environmentId);
8689 const relativePath = savePath.trim();
8790 if (!api || !workspaceRoot) {
8891 return;
apps/web/src/components/chat/TraitsPicker.browser.tsx+30-24
......@@ -6,10 +6,11 @@ import {
66 CodexModelOptions,
77 DEFAULT_MODEL_BY_PROVIDER,
88 DEFAULT_SERVER_SETTINGS,
9 ProjectId,
9 EnvironmentId,
1010 type ServerProvider,
1111 ThreadId,
1212} from "@t3tools/contracts";
13import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
1314import { page } from "vitest/browser";
1415import { useCallback } from "react";
1516import { afterEach, describe, expect, it, vi } from "vitest";
......@@ -27,7 +28,13 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
2728
2829// ── Claude TraitsPicker tests ─────────────────────────────────────────
2930
31const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local");
3032const 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);
3138const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
3239 {
3340 provider: "codex",
......@@ -120,10 +127,10 @@ function ClaudeTraitsPickerHarness(props: {
120127 fallbackModelSelection: ModelSelection | null;
121128 triggerVariant?: "ghost" | "outline";
122129}) {
123 const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt;
130 const prompt = useComposerThreadDraft(CLAUDE_THREAD_REF).prompt;
124131 const setPrompt = useComposerDraftStore((store) => store.setPrompt);
125132 const { modelOptions, selectedModel } = useEffectiveComposerModelState({
126 threadId: CLAUDE_THREAD_ID,
133 threadRef: CLAUDE_THREAD_REF,
127134 providers: TEST_PROVIDERS,
128135 selectedProvider: "claudeAgent",
129136 threadModelSelection: props.fallbackModelSelection,
......@@ -135,7 +142,7 @@ function ClaudeTraitsPickerHarness(props: {
135142 });
136143 const handlePromptChange = useCallback(
137144 (nextPrompt: string) => {
138 setPrompt(CLAUDE_THREAD_ID, nextPrompt);
145 setPrompt(CLAUDE_THREAD_REF, nextPrompt);
139146 },
140147 [setPrompt],
141148 );
......@@ -144,7 +151,7 @@ function ClaudeTraitsPickerHarness(props: {
144151 <TraitsPicker
145152 provider="claudeAgent"
146153 models={TEST_PROVIDERS[1]!.models}
147 threadId={CLAUDE_THREAD_ID}
154 threadRef={CLAUDE_THREAD_REF}
148155 model={selectedModel ?? props.model}
149156 prompt={prompt}
150157 modelOptions={modelOptions?.claudeAgent}
......@@ -168,8 +175,8 @@ async function mountClaudePicker(props?: {
168175}) {
169176 const model = props?.model ?? "claude-opus-4-6";
170177 const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined;
171 const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = {
172 [CLAUDE_THREAD_ID]: {
178 const draftsByThreadKey: Record<string, ComposerThreadDraftState> = {
179 [CLAUDE_THREAD_KEY]: {
173180 prompt: props?.prompt ?? "",
174181 images: [],
175182 nonPersistedImageIds: [],
......@@ -192,9 +199,9 @@ async function mountClaudePicker(props?: {
192199 },
193200 };
194201 useComposerDraftStore.setState({
195 draftsByThreadId,
196 draftThreadsByThreadId: {},
197 projectDraftThreadIdByProjectId: {},
202 draftsByThreadKey,
203 draftThreadsByThreadKey: {},
204 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
198205 });
199206 const host = document.createElement("div");
200207 document.body.append(host);
......@@ -230,9 +237,9 @@ describe("TraitsPicker (Claude)", () => {
230237 afterEach(() => {
231238 document.body.innerHTML = "";
232239 useComposerDraftStore.setState({
233 draftsByThreadId: {},
234 draftThreadsByThreadId: {},
235 projectDraftThreadIdByProjectId: {},
240 draftsByThreadKey: {},
241 draftThreadsByThreadKey: {},
242 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
236243 stickyModelSelectionByProvider: {},
237244 });
238245 });
......@@ -369,10 +376,9 @@ describe("TraitsPicker (Claude)", () => {
369376// ── Codex TraitsPicker tests ──────────────────────────────────────────
370377
371378async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) {
372 const threadId = ThreadId.makeUnsafe("thread-codex-traits");
373379 const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex;
374 const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = {
375 [threadId]: {
380 const draftsByThreadKey: Record<string, ComposerThreadDraftState> = {
381 [CODEX_THREAD_KEY]: {
376382 prompt: "",
377383 images: [],
378384 nonPersistedImageIds: [],
......@@ -392,10 +398,10 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt
392398 };
393399
394400 useComposerDraftStore.setState({
395 draftsByThreadId,
396 draftThreadsByThreadId: {},
397 projectDraftThreadIdByProjectId: {
398 [ProjectId.makeUnsafe("project-codex-traits")]: threadId,
401 draftsByThreadKey,
402 draftThreadsByThreadKey: {},
403 logicalProjectDraftThreadKeyByLogicalProjectKey: {
404 "environment-local:project-codex-traits": CODEX_THREAD_KEY,
399405 },
400406 });
401407 const host = document.createElement("div");
......@@ -404,7 +410,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt
404410 <TraitsPicker
405411 provider="codex"
406412 models={TEST_PROVIDERS[0]!.models}
407 threadId={threadId}
413 threadRef={CODEX_THREAD_REF}
408414 model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex}
409415 prompt=""
410416 modelOptions={props.options}
......@@ -429,9 +435,9 @@ describe("TraitsPicker (Codex)", () => {
429435 document.body.innerHTML = "";
430436 localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY);
431437 useComposerDraftStore.setState({
432 draftsByThreadId: {},
433 draftThreadsByThreadId: {},
434 projectDraftThreadIdByProjectId: {},
438 draftsByThreadKey: {},
439 draftThreadsByThreadKey: {},
440 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
435441 stickyModelSelectionByProvider: {},
436442 });
437443 });
apps/web/src/components/chat/TraitsPicker.tsx+12-5
......@@ -3,8 +3,8 @@ import {
33 type CodexModelOptions,
44 type ProviderKind,
55 type ProviderModelOptions,
6 type ScopedThreadRef,
67 type ServerProviderModel,
7 type ThreadId,
88} from "@t3tools/contracts";
99import {
1010 applyClaudePromptEffortPrefix,
......@@ -28,18 +28,19 @@ import {
2828 MenuSeparator as MenuDivider,
2929 MenuTrigger,
3030} from "../ui/menu";
31import { useComposerDraftStore } from "../../composerDraftStore";
31import { useComposerDraftStore, DraftId } from "../../composerDraftStore";
3232import { getProviderModelCapabilities } from "../../providerModels";
3333import { cn } from "~/lib/utils";
3434
3535type ProviderOptions = ProviderModelOptions[ProviderKind];
3636type TraitsPersistence =
3737 | {
38 threadId: ThreadId;
38 threadRef?: ScopedThreadRef;
39 draftId?: DraftId;
3940 onModelOptionsChange?: never;
4041 }
4142 | {
42 threadId?: undefined;
43 threadRef?: undefined;
4344 onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void;
4445 };
4546
......@@ -167,7 +168,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
167168 persistence.onModelOptionsChange(nextOptions);
168169 return;
169170 }
170 setProviderModelOptions(persistence.threadId, provider, nextOptions, { persistSticky: true });
171 const threadTarget = persistence.threadRef ?? persistence.draftId;
172 if (!threadTarget) {
173 return;
174 }
175 setProviderModelOptions(threadTarget, provider, nextOptions, {
176 persistSticky: true,
177 });
171178 },
172179 [persistence, provider, setProviderModelOptions],
173180 );
apps/web/src/components/chat/composerProviderRegistry.test.tsx+33-1
......@@ -1,6 +1,10 @@
11import { describe, expect, it } from "vitest";
22import type { ServerProviderModel } from "@t3tools/contracts";
3import { getComposerProviderState } from "./composerProviderRegistry";
3import {
4 getComposerProviderState,
5 renderProviderTraitsMenuContent,
6 renderProviderTraitsPicker,
7} from "./composerProviderRegistry";
48
59const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [
610 {
......@@ -417,3 +421,31 @@ describe("getComposerProviderState", () => {
417421 expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode");
418422 });
419423});
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 @@
11import {
22 type ProviderKind,
33 type ProviderModelOptions,
4 type ScopedThreadRef,
45 type ServerProviderModel,
5 type ThreadId,
66} from "@t3tools/contracts";
77import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model";
88import type { ReactNode } from "react";
9import type { DraftId } from "../../composerDraftStore";
910import { getProviderModelCapabilities } from "../../providerModels";
1011import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
1112import {
......@@ -33,7 +34,8 @@ export type ComposerProviderState = {
3334type ProviderRegistryEntry = {
3435 getState: (input: ComposerProviderStateInput) => ComposerProviderState;
3536 renderTraitsMenuContent: (input: {
36 threadId: ThreadId;
37 threadRef?: ScopedThreadRef;
38 draftId?: DraftId;
3739 model: string;
3840 models: ReadonlyArray<ServerProviderModel>;
3941 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
......@@ -41,7 +43,8 @@ type ProviderRegistryEntry = {
4143 onPromptChange: (prompt: string) => void;
4244 }) => ReactNode;
4345 renderTraitsPicker: (input: {
44 threadId: ThreadId;
46 threadRef?: ScopedThreadRef;
47 draftId?: DraftId;
4548 model: string;
4649 models: ReadonlyArray<ServerProviderModel>;
4750 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
......@@ -50,6 +53,13 @@ type ProviderRegistryEntry = {
5053 }) => ReactNode;
5154};
5255
56function hasComposerTraitsTarget(input: {
57 threadRef: ScopedThreadRef | undefined;
58 draftId: DraftId | undefined;
59}): boolean {
60 return input.threadRef !== undefined || input.draftId !== undefined;
61}
62
5363function getProviderStateFromCapabilities(
5464 input: ComposerProviderStateInput,
5565): ComposerProviderState {
......@@ -94,66 +104,92 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
94104 codex: {
95105 getState: (input) => getProviderStateFromCapabilities(input),
96106 renderTraitsMenuContent: ({
97 threadId,
107 threadRef,
108 draftId,
109 model,
110 models,
111 modelOptions,
112 prompt,
113 onPromptChange,
114 }) =>
115 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
116 <TraitsMenuContent
117 provider="codex"
118 models={models}
119 {...(threadRef ? { threadRef } : {})}
120 {...(draftId ? { draftId } : {})}
121 model={model}
122 modelOptions={modelOptions}
123 prompt={prompt}
124 onPromptChange={onPromptChange}
125 />
126 ),
127 renderTraitsPicker: ({
128 threadRef,
129 draftId,
98130 model,
99131 models,
100132 modelOptions,
101133 prompt,
102134 onPromptChange,
103 }) => (
104 <TraitsMenuContent
105 provider="codex"
106 models={models}
107 threadId={threadId}
108 model={model}
109 modelOptions={modelOptions}
110 prompt={prompt}
111 onPromptChange={onPromptChange}
112 />
113 ),
114 renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => (
115 <TraitsPicker
116 provider="codex"
117 models={models}
118 threadId={threadId}
119 model={model}
120 modelOptions={modelOptions}
121 prompt={prompt}
122 onPromptChange={onPromptChange}
123 />
124 ),
135 }) =>
136 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
137 <TraitsPicker
138 provider="codex"
139 models={models}
140 {...(threadRef ? { threadRef } : {})}
141 {...(draftId ? { draftId } : {})}
142 model={model}
143 modelOptions={modelOptions}
144 prompt={prompt}
145 onPromptChange={onPromptChange}
146 />
147 ),
125148 },
126149 claudeAgent: {
127150 getState: (input) => getProviderStateFromCapabilities(input),
128151 renderTraitsMenuContent: ({
129 threadId,
152 threadRef,
153 draftId,
154 model,
155 models,
156 modelOptions,
157 prompt,
158 onPromptChange,
159 }) =>
160 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
161 <TraitsMenuContent
162 provider="claudeAgent"
163 models={models}
164 {...(threadRef ? { threadRef } : {})}
165 {...(draftId ? { draftId } : {})}
166 model={model}
167 modelOptions={modelOptions}
168 prompt={prompt}
169 onPromptChange={onPromptChange}
170 />
171 ),
172 renderTraitsPicker: ({
173 threadRef,
174 draftId,
130175 model,
131176 models,
132177 modelOptions,
133178 prompt,
134179 onPromptChange,
135 }) => (
136 <TraitsMenuContent
137 provider="claudeAgent"
138 models={models}
139 threadId={threadId}
140 model={model}
141 modelOptions={modelOptions}
142 prompt={prompt}
143 onPromptChange={onPromptChange}
144 />
145 ),
146 renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => (
147 <TraitsPicker
148 provider="claudeAgent"
149 models={models}
150 threadId={threadId}
151 model={model}
152 modelOptions={modelOptions}
153 prompt={prompt}
154 onPromptChange={onPromptChange}
155 />
156 ),
180 }) =>
181 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
182 <TraitsPicker
183 provider="claudeAgent"
184 models={models}
185 {...(threadRef ? { threadRef } : {})}
186 {...(draftId ? { draftId } : {})}
187 model={model}
188 modelOptions={modelOptions}
189 prompt={prompt}
190 onPromptChange={onPromptChange}
191 />
192 ),
157193 },
158194};
159195
......@@ -163,7 +199,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
163199
164200export function renderProviderTraitsMenuContent(input: {
165201 provider: ProviderKind;
166 threadId: ThreadId;
202 threadRef?: ScopedThreadRef;
203 draftId?: DraftId;
167204 model: string;
168205 models: ReadonlyArray<ServerProviderModel>;
169206 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
......@@ -171,7 +208,8 @@ export function renderProviderTraitsMenuContent(input: {
171208 onPromptChange: (prompt: string) => void;
172209}): ReactNode {
173210 return composerProviderRegistry[input.provider].renderTraitsMenuContent({
174 threadId: input.threadId,
211 ...(input.threadRef ? { threadRef: input.threadRef } : {}),
212 ...(input.draftId ? { draftId: input.draftId } : {}),
175213 model: input.model,
176214 models: input.models,
177215 modelOptions: input.modelOptions,
......@@ -182,7 +220,8 @@ export function renderProviderTraitsMenuContent(input: {
182220
183221export function renderProviderTraitsPicker(input: {
184222 provider: ProviderKind;
185 threadId: ThreadId;
223 threadRef?: ScopedThreadRef;
224 draftId?: DraftId;
186225 model: string;
187226 models: ReadonlyArray<ServerProviderModel>;
188227 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
......@@ -190,7 +229,8 @@ export function renderProviderTraitsPicker(input: {
190229 onPromptChange: (prompt: string) => void;
191230}): ReactNode {
192231 return composerProviderRegistry[input.provider].renderTraitsPicker({
193 threadId: input.threadId,
232 ...(input.threadRef ? { threadRef: input.threadRef } : {}),
233 ...(input.draftId ? { draftId: input.draftId } : {}),
194234 model: input.model,
195235 models: input.models,
196236 modelOptions: input.modelOptions,
apps/web/src/components/settings/SettingsPanels.browser.tsx+18-6
......@@ -1,17 +1,29 @@
11import "../../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";
49import { page } from "vitest/browser";
510import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
611import { render } from "vitest-browser-react";
712
8import { __resetNativeApiForTests } from "../../nativeApi";
13import { __resetLocalApiForTests } from "../../localApi";
914import { AppAtomRegistryProvider } from "../../rpc/atomRegistry";
1015import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
1116import { GeneralSettingsPanel } from "./SettingsPanels";
1217
1318function createBaseServerConfig(): ServerConfig {
1419 return {
20 environment: {
21 environmentId: EnvironmentId.makeUnsafe("environment-local"),
22 label: "Local environment",
23 platform: { os: "darwin" as const, arch: "arm64" as const },
24 serverVersion: "0.0.0-test",
25 capabilities: { repositoryIdentity: true },
26 },
1527 cwd: "/repo/project",
1628 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
1729 keybindings: [],
......@@ -32,14 +44,14 @@ function createBaseServerConfig(): ServerConfig {
3244describe("GeneralSettingsPanel observability", () => {
3345 beforeEach(async () => {
3446 resetServerStateForTests();
35 await __resetNativeApiForTests();
47 await __resetLocalApiForTests();
3648 localStorage.clear();
3749 document.body.innerHTML = "";
3850 });
3951
4052 afterEach(async () => {
4153 resetServerStateForTests();
42 await __resetNativeApiForTests();
54 await __resetLocalApiForTests();
4355 document.body.innerHTML = "";
4456 });
4557
......@@ -68,12 +80,12 @@ describe("GeneralSettingsPanel observability", () => {
6880 });
6981
7082 it("opens the logs folder in the preferred editor", async () => {
71 const openInEditor = vi.fn<NativeApi["shell"]["openInEditor"]>().mockResolvedValue(undefined);
83 const openInEditor = vi.fn<LocalApi["shell"]["openInEditor"]>().mockResolvedValue(undefined);
7284 window.nativeApi = {
7385 shell: {
7486 openInEditor,
7587 },
76 } as unknown as NativeApi;
88 } as unknown as LocalApi;
7789
7890 setServerConfigSnapshot(createBaseServerConfig());
7991
apps/web/src/components/settings/SettingsPanels.tsx+36-35
......@@ -13,11 +13,12 @@ import { useQueryClient } from "@tanstack/react-query";
1313import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
1414import {
1515 PROVIDER_DISPLAY_NAMES,
16 type ScopedThreadRef,
1617 type ProviderKind,
1718 type ServerProvider,
1819 type ServerProviderModel,
19 ThreadId,
2020} from "@t3tools/contracts";
21import { scopeThreadRef } from "@t3tools/client-runtime";
2122import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
2223import { normalizeModelSlug } from "@t3tools/shared/model";
2324import { Equal } from "effect";
......@@ -45,8 +46,12 @@ import {
4546 getCustomModelOptionsByProvider,
4647 resolveAppModelSelectionState,
4748} from "../../modelSelection";
48import { ensureNativeApi, readNativeApi } from "../../nativeApi";
49import { useStore } from "../../store";
49import { ensureLocalApi, readLocalApi } from "../../localApi";
50import {
51 selectProjectsAcrossEnvironments,
52 selectThreadsAcrossEnvironments,
53 useStore,
54} from "../../store";
5055import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
5156import { cn } from "../../lib/utils";
5257import { Button } from "../ui/button";
......@@ -496,8 +501,8 @@ export function useSettingsRestore(onRestored?: () => void) {
496501
497502 const restoreDefaults = useCallback(async () => {
498503 if (changedSettingLabels.length === 0) return;
499 const api = readNativeApi();
500 const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm(
504 const api = readLocalApi();
505 const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm(
501506 ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(
502507 "\n",
503508 ),
......@@ -554,7 +559,7 @@ export function GeneralSettingsPanel() {
554559 if (refreshingRef.current) return;
555560 refreshingRef.current = true;
556561 setIsRefreshingProviders(true);
557 void ensureNativeApi()
562 void ensureLocalApi()
558563 .server.refreshProviders()
559564 .catch((error: unknown) => {
560565 console.warn("Failed to refresh providers", error);
......@@ -614,7 +619,7 @@ export function GeneralSettingsPanel() {
614619 return;
615620 }
616621
617 void ensureNativeApi()
622 void ensureLocalApi()
618623 .shell.openInEditor(path, editor)
619624 .catch((error) => {
620625 setOpenPathErrorByTarget((existing) => ({
......@@ -1479,20 +1484,10 @@ export function GeneralSettingsPanel() {
14791484}
14801485
14811486export function ArchivedThreadsPanel() {
1482 const projectIds = useStore((store) => store.projectIds);
1483 const projectById = useStore((store) => store.projectById);
1484 const threadIds = useStore((store) => store.threadIds);
1485 const threadShellById = useStore((store) => store.threadShellById);
1487 const projects = useStore(selectProjectsAcrossEnvironments);
1488 const threads = useStore(selectThreadsAcrossEnvironments);
14861489 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
14871490 const archivedGroups = useMemo(() => {
1488 const projects = projectIds.flatMap((projectId) => {
1489 const project = projectById[projectId];
1490 return project ? [project] : [];
1491 });
1492 const threads = threadIds.flatMap((threadId) => {
1493 const thread = threadShellById[threadId];
1494 return thread ? [thread] : [];
1495 });
14961491 return projects
14971492 .map((project) => ({
14981493 project,
......@@ -1505,11 +1500,11 @@ export function ArchivedThreadsPanel() {
15051500 }),
15061501 }))
15071502 .filter((group) => group.threads.length > 0);
1508 }, [projectById, projectIds, threadIds, threadShellById]);
1503 }, [projects, threads]);
15091504
15101505 const handleArchivedThreadContextMenu = useCallback(
1511 async (threadId: ThreadId, position: { x: number; y: number }) => {
1512 const api = readNativeApi();
1506 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
1507 const api = readLocalApi();
15131508 if (!api) return;
15141509 const clicked = await api.contextMenu.show(
15151510 [
......@@ -1521,7 +1516,7 @@ export function ArchivedThreadsPanel() {
15211516
15221517 if (clicked === "unarchive") {
15231518 try {
1524 await unarchiveThread(threadId);
1519 await unarchiveThread(threadRef);
15251520 } catch (error) {
15261521 toastManager.add({
15271522 type: "error",
......@@ -1533,7 +1528,7 @@ export function ArchivedThreadsPanel() {
15331528 }
15341529
15351530 if (clicked === "delete") {
1536 await confirmAndDeleteThread(threadId);
1531 await confirmAndDeleteThread(threadRef);
15371532 }
15381533 },
15391534 [confirmAndDeleteThread, unarchiveThread],
......@@ -1566,10 +1561,13 @@ export function ArchivedThreadsPanel() {
15661561 className="flex items-center justify-between gap-3 border-t border-border px-4 py-3 first:border-t-0 sm:px-5"
15671562 onContextMenu={(event) => {
15681563 event.preventDefault();
1569 void handleArchivedThreadContextMenu(thread.id, {
1570 x: event.clientX,
1571 y: event.clientY,
1572 });
1564 void handleArchivedThreadContextMenu(
1565 scopeThreadRef(thread.environmentId, thread.id),
1566 {
1567 x: event.clientX,
1568 y: event.clientY,
1569 },
1570 );
15731571 }}
15741572 >
15751573 <div className="min-w-0 flex-1">
......@@ -1586,13 +1584,16 @@ export function ArchivedThreadsPanel() {
15861584 size="sm"
15871585 className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5"
15881586 onClick={() =>
1589 void unarchiveThread(thread.id).catch((error) => {
1590 toastManager.add({
1591 type: "error",
1592 title: "Failed to unarchive thread",
1593 description: error instanceof Error ? error.message : "An error occurred.",
1594 });
1595 })
1587 void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch(
1588 (error) => {
1589 toastManager.add({
1590 type: "error",
1591 title: "Failed to unarchive thread",
1592 description:
1593 error instanceof Error ? error.message : "An error occurred.",
1594 });
1595 },
1596 )
15961597 }
15971598 >
15981599 <ArchiveX className="size-3.5" />
apps/web/src/composerDraftStore.test.ts+302-188
......@@ -1,5 +1,12 @@
1import {
2 scopedProjectKey,
3 scopedThreadKey,
4 scopeProjectRef,
5 scopeThreadRef,
6} from "@t3tools/client-runtime";
17import * as Schema from "effect/Schema";
28import {
9 EnvironmentId,
310 ProjectId,
411 ThreadId,
512 type ModelSelection,
......@@ -9,10 +16,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
916
1017import {
1118 COMPOSER_DRAFT_STORAGE_KEY,
12 clearPromotedDraftThread,
13 clearPromotedDraftThreads,
19 finalizePromotedDraftThreadByRef,
20 markPromotedDraftThread,
21 markPromotedDraftThreadByRef,
22 markPromotedDraftThreads,
23 markPromotedDraftThreadsByRef,
1424 type ComposerImageAttachment,
1525 useComposerDraftStore,
26 DraftId,
1627} from "./composerDraftStore";
1728import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";
1829import {
......@@ -71,9 +82,9 @@ function makeTerminalContext(input: {
7182
7283function resetComposerDraftStore() {
7384 useComposerDraftStore.setState({
74 draftsByThreadId: {},
75 draftThreadsByThreadId: {},
76 projectDraftThreadIdByProjectId: {},
85 draftsByThreadKey: {},
86 draftThreadsByThreadKey: {},
87 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
7788 stickyModelSelectionByProvider: {},
7889 stickyActiveProvider: null,
7990 });
......@@ -95,8 +106,32 @@ function providerModelOptions(options: ProviderModelOptions): ProviderModelOptio
95106 return options;
96107}
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
98132describe("composerDraftStore addImages", () => {
99133 const threadId = ThreadId.makeUnsafe("thread-dedupe");
134 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
100135 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
101136 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
102137
......@@ -129,9 +164,9 @@ describe("composerDraftStore addImages", () => {
129164 lastModified: 12345,
130165 });
131166
132 useComposerDraftStore.getState().addImages(threadId, [first, duplicate]);
167 useComposerDraftStore.getState().addImages(threadRef, [first, duplicate]);
133168
134 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
169 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
135170 expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]);
136171 expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate");
137172 });
......@@ -154,10 +189,10 @@ describe("composerDraftStore addImages", () => {
154189 lastModified: 999,
155190 });
156191
157 useComposerDraftStore.getState().addImage(threadId, first);
158 useComposerDraftStore.getState().addImage(threadId, duplicateLater);
192 useComposerDraftStore.getState().addImage(threadRef, first);
193 useComposerDraftStore.getState().addImage(threadRef, duplicateLater);
159194
160 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
195 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
161196 expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]);
162197 expect(revokeSpy).toHaveBeenCalledWith("blob:b");
163198 });
......@@ -172,9 +207,9 @@ describe("composerDraftStore addImages", () => {
172207 previewUrl: "blob:shared",
173208 });
174209
175 useComposerDraftStore.getState().addImages(threadId, [first, duplicateSameUrl]);
210 useComposerDraftStore.getState().addImages(threadRef, [first, duplicateSameUrl]);
176211
177 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
212 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
178213 expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]);
179214 expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared");
180215 });
......@@ -182,6 +217,7 @@ describe("composerDraftStore addImages", () => {
182217
183218describe("composerDraftStore clearComposerContent", () => {
184219 const threadId = ThreadId.makeUnsafe("thread-clear");
220 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
185221 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
186222 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
187223
......@@ -201,11 +237,11 @@ describe("composerDraftStore clearComposerContent", () => {
201237 id: "img-optimistic",
202238 previewUrl: "blob:optimistic",
203239 });
204 useComposerDraftStore.getState().addImage(threadId, first);
240 useComposerDraftStore.getState().addImage(threadRef, first);
205241
206 useComposerDraftStore.getState().clearComposerContent(threadId);
242 useComposerDraftStore.getState().clearComposerContent(threadRef);
207243
208 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
244 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
209245 expect(draft).toBeUndefined();
210246 expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic");
211247 });
......@@ -213,13 +249,14 @@ describe("composerDraftStore clearComposerContent", () => {
213249
214250describe("composerDraftStore syncPersistedAttachments", () => {
215251 const threadId = ThreadId.makeUnsafe("thread-sync-persisted");
252 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
216253
217254 beforeEach(() => {
218255 removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY);
219256 useComposerDraftStore.setState({
220 draftsByThreadId: {},
221 draftThreadsByThreadId: {},
222 projectDraftThreadIdByProjectId: {},
257 draftsByThreadKey: {},
258 draftThreadsByThreadKey: {},
259 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
223260 stickyModelSelectionByProvider: {},
224261 stickyActiveProvider: null,
225262 });
......@@ -234,7 +271,7 @@ describe("composerDraftStore syncPersistedAttachments", () => {
234271 id: "img-persisted",
235272 previewUrl: "blob:persisted",
236273 });
237 useComposerDraftStore.getState().addImage(threadId, image);
274 useComposerDraftStore.getState().addImage(threadRef, image);
238275 setLocalStorageItem(
239276 COMPOSER_DRAFT_STORAGE_KEY,
240277 {
......@@ -250,7 +287,7 @@ describe("composerDraftStore syncPersistedAttachments", () => {
250287 Schema.Unknown,
251288 );
252289
253 useComposerDraftStore.getState().syncPersistedAttachments(threadId, [
290 useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [
254291 {
255292 id: image.id,
256293 name: image.name,
......@@ -261,23 +298,20 @@ describe("composerDraftStore syncPersistedAttachments", () => {
261298 ]);
262299 await Promise.resolve();
263300
264 expect(
265 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments,
266 ).toEqual([]);
267 expect(
268 useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedImageIds,
269 ).toEqual([image.id]);
301 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual([]);
302 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.nonPersistedImageIds).toEqual([image.id]);
270303 });
271304});
272305
273306describe("composerDraftStore terminal contexts", () => {
274307 const threadId = ThreadId.makeUnsafe("thread-dedupe");
308 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
275309
276310 beforeEach(() => {
277311 useComposerDraftStore.setState({
278 draftsByThreadId: {},
279 draftThreadsByThreadId: {},
280 projectDraftThreadIdByProjectId: {},
312 draftsByThreadKey: {},
313 draftThreadsByThreadKey: {},
314 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
281315 stickyModelSelectionByProvider: {},
282316 stickyActiveProvider: null,
283317 });
......@@ -287,20 +321,20 @@ describe("composerDraftStore terminal contexts", () => {
287321 const first = makeTerminalContext({ id: "ctx-1" });
288322 const duplicate = makeTerminalContext({ id: "ctx-2" });
289323
290 useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]);
324 useComposerDraftStore.getState().addTerminalContexts(threadRef, [first, duplicate]);
291325
292 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
326 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
293327 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]);
294328 });
295329
296330 it("clears terminal contexts when clearing composer content", () => {
297331 useComposerDraftStore
298332 .getState()
299 .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-1" }));
333 .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-1" }));
300334
301 useComposerDraftStore.getState().clearComposerContent(threadId);
335 useComposerDraftStore.getState().clearComposerContent(threadRef);
302336
303 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
337 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
304338 });
305339
306340 it("inserts terminal contexts at the requested inline prompt position", () => {
......@@ -311,7 +345,7 @@ describe("composerDraftStore terminal contexts", () => {
311345 useComposerDraftStore
312346 .getState()
313347 .insertTerminalContext(
314 threadId,
348 threadRef,
315349 firstInsertion.prompt,
316350 makeTerminalContext({ id: "ctx-1" }),
317351 firstInsertion.contextIndex,
......@@ -319,7 +353,7 @@ describe("composerDraftStore terminal contexts", () => {
319353 ).toBe(true);
320354 expect(
321355 useComposerDraftStore.getState().insertTerminalContext(
322 threadId,
356 threadRef,
323357 secondInsertion.prompt,
324358 makeTerminalContext({
325359 id: "ctx-2",
......@@ -331,7 +365,7 @@ describe("composerDraftStore terminal contexts", () => {
331365 ),
332366 ).toBe(true);
333367
334 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
368 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
335369 expect(draft?.prompt).toBe(
336370 `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`,
337371 );
......@@ -341,7 +375,7 @@ describe("composerDraftStore terminal contexts", () => {
341375 it("omits terminal context text from persisted drafts", () => {
342376 useComposerDraftStore
343377 .getState()
344 .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-persist" }));
378 .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" }));
345379
346380 const persistApi = useComposerDraftStore.persist as unknown as {
347381 getOptions: () => {
......@@ -349,11 +383,12 @@ describe("composerDraftStore terminal contexts", () => {
349383 };
350384 };
351385 const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {
352 draftsByThreadId?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>;
386 draftsByThreadKey?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>;
353387 };
354388
355389 expect(
356 persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0],
390 persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]
391 ?.terminalContexts?.[0],
357392 "Expected terminal context metadata to be persisted.",
358393 ).toMatchObject({
359394 id: "ctx-persist",
......@@ -363,7 +398,8 @@ describe("composerDraftStore terminal contexts", () => {
363398 lineEnd: 5,
364399 });
365400 expect(
366 persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0]?.text,
401 persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]
402 ?.terminalContexts?.[0]?.text,
367403 ).toBeUndefined();
368404 });
369405
......@@ -396,12 +432,12 @@ describe("composerDraftStore terminal contexts", () => {
396432 },
397433 },
398434 draftThreadsByThreadId: {},
399 projectDraftThreadIdByProjectId: {},
435 projectDraftThreadIdByProjectKey: {},
400436 },
401437 useComposerDraftStore.getInitialState(),
402438 );
403439
404 expect(mergedState.draftsByThreadId[threadId]?.terminalContexts).toMatchObject([
440 expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.terminalContexts).toMatchObject([
405441 {
406442 id: "ctx-rehydrated",
407443 terminalId: "default",
......@@ -434,22 +470,30 @@ describe("composerDraftStore terminal contexts", () => {
434470 },
435471 },
436472 draftThreadsByThreadId: "not-an-object",
437 projectDraftThreadIdByProjectId: "not-an-object",
473 projectDraftThreadIdByProjectKey: "not-an-object",
438474 },
439475 useComposerDraftStore.getInitialState(),
440476 );
441477
442 expect(mergedState.draftsByThreadId[threadId]).toBeUndefined();
443 expect(mergedState.draftThreadsByThreadId).toEqual({});
444 expect(mergedState.projectDraftThreadIdByProjectId).toEqual({});
478 expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined();
479 expect(mergedState.draftThreadsByThreadKey).toEqual({});
480 expect(mergedState.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual({});
445481 });
446482});
447483
448484describe("composerDraftStore project draft thread mapping", () => {
449485 const projectId = ProjectId.makeUnsafe("project-a");
450486 const otherProjectId = ProjectId.makeUnsafe("project-b");
487 const projectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, projectId);
488 const otherProjectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, otherProjectId);
489 const remoteProjectRef = scopeProjectRef(OTHER_TEST_ENVIRONMENT_ID, projectId);
451490 const threadId = ThreadId.makeUnsafe("thread-a");
452491 const otherThreadId = ThreadId.makeUnsafe("thread-b");
492 const draftId = DraftId.makeUnsafe("draft-a");
493 const otherDraftId = DraftId.makeUnsafe("draft-b");
494 const sharedDraftId = DraftId.makeUnsafe("draft-shared");
495 const localDraftId = DraftId.makeUnsafe("draft-local");
496 const remoteDraftId = DraftId.makeUnsafe("draft-remote");
453497
454498 beforeEach(() => {
455499 resetComposerDraftStore();
......@@ -457,17 +501,20 @@ describe("composerDraftStore project draft thread mapping", () => {
457501
458502 it("stores and reads project draft thread ids via actions", () => {
459503 const store = useComposerDraftStore.getState();
460 expect(store.getDraftThreadByProjectId(projectId)).toBeNull();
461 expect(store.getDraftThread(threadId)).toBeNull();
504 expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
505 expect(store.getDraftThread(draftId)).toBeNull();
462506
463 store.setProjectDraftThreadId(projectId, threadId, {
507 store.setProjectDraftThreadId(projectRef, draftId, {
508 threadId,
464509 branch: "feature/test",
465510 worktreePath: "/tmp/worktree-test",
466511 createdAt: "2026-01-01T00:00:00.000Z",
467512 });
468 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({
513 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toMatchObject({
469514 threadId,
515 environmentId: TEST_ENVIRONMENT_ID,
470516 projectId,
517 logicalProjectKey: scopedProjectKey(projectRef),
471518 branch: "feature/test",
472519 worktreePath: "/tmp/worktree-test",
473520 envMode: "worktree",
......@@ -475,8 +522,10 @@ describe("composerDraftStore project draft thread mapping", () => {
475522 interactionMode: "default",
476523 createdAt: "2026-01-01T00:00:00.000Z",
477524 });
478 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({
525 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
526 environmentId: TEST_ENVIRONMENT_ID,
479527 projectId,
528 logicalProjectKey: scopedProjectKey(projectRef),
480529 branch: "feature/test",
481530 worktreePath: "/tmp/worktree-test",
482531 envMode: "worktree",
......@@ -488,134 +537,210 @@ describe("composerDraftStore project draft thread mapping", () => {
488537
489538 it("clears only matching project draft mapping entries", () => {
490539 const store = useComposerDraftStore.getState();
491 store.setProjectDraftThreadId(projectId, threadId);
492 store.setPrompt(threadId, "hello");
540 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
541 store.setPrompt(draftId, "hello");
493542
494 store.clearProjectDraftThreadById(projectId, otherThreadId);
495 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
543 store.clearProjectDraftThreadById(projectRef, otherDraftId);
544 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
496545 threadId,
497546 );
498547
499 store.clearProjectDraftThreadById(projectId, threadId);
500 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
501 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
502 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
548 store.clearProjectDraftThreadById(projectRef, draftId);
549 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
550 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
551 expect(draftByKey(draftId)).toBeUndefined();
503552 });
504553
505554 it("clears project draft mapping by project id", () => {
506555 const store = useComposerDraftStore.getState();
507 store.setProjectDraftThreadId(projectId, threadId);
508 store.setPrompt(threadId, "hello");
509 store.clearProjectDraftThreadId(projectId);
510 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
511 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
512 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
556 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
557 store.setPrompt(draftId, "hello");
558 store.clearProjectDraftThreadId(projectRef);
559 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
560 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
561 expect(draftByKey(draftId)).toBeUndefined();
513562 });
514563
515564 it("clears orphaned composer drafts when remapping a project to a new draft thread", () => {
516565 const store = useComposerDraftStore.getState();
517 store.setProjectDraftThreadId(projectId, threadId);
518 store.setPrompt(threadId, "orphan me");
566 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
567 store.setPrompt(draftId, "orphan me");
519568
520 store.setProjectDraftThreadId(projectId, otherThreadId);
569 store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId });
521570
522 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
571 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
523572 otherThreadId,
524573 );
525 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
526 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
574 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
575 expect(draftByKey(draftId)).toBeUndefined();
527576 });
528577
529578 it("keeps composer drafts when the thread is still mapped by another project", () => {
530579 const store = useComposerDraftStore.getState();
531 store.setProjectDraftThreadId(projectId, threadId);
532 store.setProjectDraftThreadId(otherProjectId, threadId);
533 store.setPrompt(threadId, "keep me");
580 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
581 store.setProjectDraftThreadId(otherProjectRef, sharedDraftId, { threadId });
582 store.setPrompt(sharedDraftId, "keep me");
534583
535 store.clearProjectDraftThreadId(projectId);
584 store.clearProjectDraftThreadId(projectRef);
536585
537 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
586 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
538587 expect(
539 useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId,
588 useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId,
540589 ).toBe(threadId);
541 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");
590 expect(draftByKey(sharedDraftId)?.prompt).toBe("keep me");
542591 });
543592
544593 it("clears draft registration independently", () => {
545594 const store = useComposerDraftStore.getState();
546 store.setProjectDraftThreadId(projectId, threadId);
547 store.setPrompt(threadId, "remove me");
548 store.clearDraftThread(threadId);
549 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
550 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
551 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
595 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
596 store.setPrompt(draftId, "remove me");
597 store.clearDraftThread(draftId);
598 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
599 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
600 expect(draftByKey(draftId)).toBeUndefined();
601 });
602
603 it("marks a promoted draft by thread id without deleting composer state", () => {
604 const store = useComposerDraftStore.getState();
605 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
606 store.setPrompt(draftId, "promote me");
607
608 markPromotedDraftThread(threadId);
609
610 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
611 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual(
612 scopeThreadRef(TEST_ENVIRONMENT_ID, threadId),
613 );
614 expect(draftByKey(draftId)?.prompt).toBe("promote me");
552615 });
553616
554 it("clears a promoted draft by thread id", () => {
617 it("reads local draft composer state through a scoped thread ref", () => {
555618 const store = useComposerDraftStore.getState();
556 store.setProjectDraftThreadId(projectId, threadId);
557 store.setPrompt(threadId, "promote me");
619 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
558620
559 clearPromotedDraftThread(threadId);
621 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
622 store.setPrompt(draftId, "scoped access");
560623
561 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
562 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
563 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
624 expect(store.getComposerDraft(draftId)?.prompt).toBe("scoped access");
625 expect(store.getComposerDraft(threadRef)?.prompt).toBe("scoped access");
564626 });
565627
566628 it("does not clear composer drafts for existing server threads during promotion cleanup", () => {
567629 const store = useComposerDraftStore.getState();
568 store.setPrompt(threadId, "keep me");
630 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
631 store.setPrompt(threadRef, "keep me");
569632
570 clearPromotedDraftThread(threadId);
633 markPromotedDraftThread(threadId);
571634
572 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
573 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");
635 expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull();
636 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me");
574637 });
575638
576 it("clears promoted drafts from an iterable of server thread ids", () => {
639 it("marks promoted drafts from an iterable of server thread ids", () => {
577640 const store = useComposerDraftStore.getState();
578 store.setProjectDraftThreadId(projectId, threadId);
579 store.setPrompt(threadId, "promote me");
580 store.setProjectDraftThreadId(otherProjectId, otherThreadId);
581 store.setPrompt(otherThreadId, "keep me");
641 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
642 store.setPrompt(draftId, "promote me");
643 store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId });
644 store.setPrompt(otherDraftId, "keep me");
582645
583 clearPromotedDraftThreads([threadId]);
646 markPromotedDraftThreads([threadId]);
584647
585 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
586 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
648 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual(
649 scopeThreadRef(TEST_ENVIRONMENT_ID, threadId),
650 );
651 expect(draftByKey(draftId)?.prompt).toBe("promote me");
587652 expect(
588 useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId,
653 useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId,
589654 ).toBe(otherThreadId);
590 expect(useComposerDraftStore.getState().draftsByThreadId[otherThreadId]?.prompt).toBe(
591 "keep me",
655 expect(draftByKey(otherDraftId)?.prompt).toBe("keep me");
656 });
657
658 it("marks every matching scoped draft when multiple environments share a thread id", () => {
659 const store = useComposerDraftStore.getState();
660 const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
661 const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId);
662
663 store.setProjectDraftThreadId(projectRef, localDraftId, { threadId });
664 store.setPrompt(localDraftId, "local draft");
665 store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { threadId });
666 store.setPrompt(remoteDraftId, "remote draft");
667
668 markPromotedDraftThread(threadId);
669
670 expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
671 expect(store.getDraftThreadByProjectRef(remoteProjectRef)).toBeNull();
672 expect(store.getDraftThreadByRef(localThreadRef)?.promotedTo).toEqual(localThreadRef);
673 expect(store.getDraftThreadByRef(remoteThreadRef)?.promotedTo).toEqual(remoteThreadRef);
674 expect(draftByKey(localDraftId)?.prompt).toBe("local draft");
675 expect(draftByKey(remoteDraftId)?.prompt).toBe("remote draft");
676 });
677
678 it("only marks promoted drafts for the matching environment ref", () => {
679 const store = useComposerDraftStore.getState();
680 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
681 store.setPrompt(draftId, "promote me");
682
683 markPromotedDraftThreadByRef(scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId));
684
685 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
686 threadId,
687 );
688 expect(draftByKey(draftId)?.prompt).toBe("promote me");
689 });
690
691 it("only marks iterable promotion cleanup entries for the matching environment refs", () => {
692 const store = useComposerDraftStore.getState();
693 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
694 store.setPrompt(draftId, "promote me");
695
696 markPromotedDraftThreadsByRef([scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId)]);
697
698 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
699 threadId,
592700 );
701 expect(draftByKey(draftId)?.prompt).toBe("promote me");
593702 });
594703
595704 it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => {
596705 const store = useComposerDraftStore.getState();
597 store.setPrompt(threadId, "keep me");
706 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
707 store.setPrompt(threadRef, "keep me");
598708
599 clearPromotedDraftThreads([threadId]);
709 markPromotedDraftThreads([threadId]);
600710
601 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
602 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");
711 expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull();
712 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me");
713 });
714
715 it("finalizes a promoted draft after the canonical thread route is active", () => {
716 const store = useComposerDraftStore.getState();
717 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
718 store.setPrompt(draftId, "promote me");
719 markPromotedDraftThread(threadId);
720
721 finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
722
723 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
724 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
725 expect(draftByKey(draftId)).toBeUndefined();
603726 });
604727
605728 it("updates branch context on an existing draft thread", () => {
606729 const store = useComposerDraftStore.getState();
607 store.setProjectDraftThreadId(projectId, threadId, {
730 store.setProjectDraftThreadId(projectRef, draftId, {
731 threadId,
608732 branch: "main",
609733 worktreePath: null,
610734 });
611 store.setDraftThreadContext(threadId, {
735 store.setDraftThreadContext(draftId, {
612736 branch: "feature/next",
613737 worktreePath: "/tmp/feature-next",
614738 });
615 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
739 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
616740 threadId,
617741 );
618 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({
742 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
743 environmentId: TEST_ENVIRONMENT_ID,
619744 projectId,
620745 branch: "feature/next",
621746 worktreePath: "/tmp/feature-next",
......@@ -625,7 +750,8 @@ describe("composerDraftStore project draft thread mapping", () => {
625750
626751 it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => {
627752 const store = useComposerDraftStore.getState();
628 store.setProjectDraftThreadId(projectId, threadId, {
753 store.setProjectDraftThreadId(projectRef, draftId, {
754 threadId,
629755 branch: "main",
630756 worktreePath: "/tmp/main-worktree",
631757 });
......@@ -636,9 +762,10 @@ describe("composerDraftStore project draft thread mapping", () => {
636762 branch?: string | null;
637763 worktreePath?: string | null;
638764 };
639 store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions);
765 store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions);
640766
641 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({
767 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
768 environmentId: TEST_ENVIRONMENT_ID,
642769 projectId,
643770 branch: "main",
644771 worktreePath: "/tmp/main-worktree",
......@@ -648,7 +775,8 @@ describe("composerDraftStore project draft thread mapping", () => {
648775
649776 it("preserves worktree env mode without a worktree path", () => {
650777 const store = useComposerDraftStore.getState();
651 store.setProjectDraftThreadId(projectId, threadId, {
778 store.setProjectDraftThreadId(projectRef, draftId, {
779 threadId,
652780 branch: "feature/base",
653781 worktreePath: null,
654782 envMode: "worktree",
......@@ -662,9 +790,10 @@ describe("composerDraftStore project draft thread mapping", () => {
662790 worktreePath?: string | null;
663791 envMode?: "local" | "worktree";
664792 };
665 store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions);
793 store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions);
666794
667 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({
795 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
796 environmentId: TEST_ENVIRONMENT_ID,
668797 projectId,
669798 branch: "feature/base",
670799 worktreePath: null,
......@@ -675,6 +804,7 @@ describe("composerDraftStore project draft thread mapping", () => {
675804
676805describe("composerDraftStore modelSelection", () => {
677806 const threadId = ThreadId.makeUnsafe("thread-model-options");
807 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
678808
679809 beforeEach(() => {
680810 resetComposerDraftStore();
......@@ -683,16 +813,14 @@ describe("composerDraftStore modelSelection", () => {
683813 it("stores a model selection in the draft", () => {
684814 const store = useComposerDraftStore.getState();
685815 store.setModelSelection(
686 threadId,
816 threadRef,
687817 modelSelection("codex", "gpt-5.3-codex", {
688818 reasoningEffort: "xhigh",
689819 fastMode: true,
690820 }),
691821 );
692822
693 expect(
694 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
695 ).toEqual(
823 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
696824 modelSelection("codex", "gpt-5.3-codex", {
697825 reasoningEffort: "xhigh",
698826 fastMode: true,
......@@ -702,18 +830,18 @@ describe("composerDraftStore modelSelection", () => {
702830
703831 it("keeps default-only model selections on the draft", () => {
704832 const store = useComposerDraftStore.getState();
705 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4"));
833 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4"));
706834
707 expect(
708 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
709 ).toEqual(modelSelection("codex", "gpt-5.4"));
835 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
836 modelSelection("codex", "gpt-5.4"),
837 );
710838 });
711839
712840 it("replaces only the targeted provider options on the current model selection", () => {
713841 const store = useComposerDraftStore.getState();
714842
715843 store.setModelSelection(
716 threadId,
844 threadRef,
717845 modelSelection("claudeAgent", "claude-opus-4-6", {
718846 effort: "max",
719847 fastMode: true,
......@@ -727,7 +855,7 @@ describe("composerDraftStore modelSelection", () => {
727855 );
728856
729857 store.setProviderModelOptions(
730 threadId,
858 threadRef,
731859 "claudeAgent",
732860 {
733861 thinking: false,
......@@ -735,10 +863,7 @@ describe("composerDraftStore modelSelection", () => {
735863 { persistSticky: true },
736864 );
737865
738 expect(
739 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
740 .claudeAgent,
741 ).toEqual(
866 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
742867 modelSelection("claudeAgent", "claude-opus-4-6", {
743868 thinking: false,
744869 }),
......@@ -754,20 +879,17 @@ describe("composerDraftStore modelSelection", () => {
754879 const store = useComposerDraftStore.getState();
755880
756881 store.setModelSelection(
757 threadId,
882 threadRef,
758883 modelSelection("claudeAgent", "claude-opus-4-6", {
759884 effort: "max",
760885 }),
761886 );
762887
763 store.setProviderModelOptions(threadId, "claudeAgent", {
888 store.setProviderModelOptions(threadRef, "claudeAgent", {
764889 thinking: true,
765890 });
766891
767 expect(
768 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
769 .claudeAgent,
770 ).toEqual(
892 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
771893 modelSelection("claudeAgent", "claude-opus-4-6", {
772894 thinking: true,
773895 }),
......@@ -778,16 +900,14 @@ describe("composerDraftStore modelSelection", () => {
778900 it("keeps explicit off/default codex overrides on the selection", () => {
779901 const store = useComposerDraftStore.getState();
780902
781 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true }));
903 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true }));
782904
783 store.setProviderModelOptions(threadId, "codex", {
905 store.setProviderModelOptions(threadRef, "codex", {
784906 reasoningEffort: "high",
785907 fastMode: false,
786908 });
787909
788 expect(
789 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
790 ).toEqual(
910 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
791911 modelSelection("codex", "gpt-5.4", {
792912 reasoningEffort: "high",
793913 fastMode: false,
......@@ -802,18 +922,15 @@ describe("composerDraftStore modelSelection", () => {
802922 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
803923 );
804924 store.setModelSelection(
805 threadId,
925 threadRef,
806926 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
807927 );
808928
809 store.setProviderModelOptions(threadId, "claudeAgent", {
929 store.setProviderModelOptions(threadRef, "claudeAgent", {
810930 thinking: false,
811931 });
812932
813 expect(
814 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
815 .claudeAgent,
816 ).toEqual(
933 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
817934 modelSelection("claudeAgent", "claude-opus-4-6", {
818935 thinking: false,
819936 }),
......@@ -828,7 +945,7 @@ describe("composerDraftStore modelSelection", () => {
828945
829946 // Set options for both providers
830947 store.setModelOptions(
831 threadId,
948 threadRef,
832949 providerModelOptions({
833950 codex: { fastMode: true },
834951 claudeAgent: { effort: "max" },
......@@ -836,9 +953,9 @@ describe("composerDraftStore modelSelection", () => {
836953 );
837954
838955 // Now set options for only codex — claudeAgent should be untouched
839 store.setModelOptions(threadId, providerModelOptions({ codex: { reasoningEffort: "xhigh" } }));
956 store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } }));
840957
841 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
958 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
842959 expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" });
843960 expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" });
844961 });
......@@ -847,16 +964,16 @@ describe("composerDraftStore modelSelection", () => {
847964 const store = useComposerDraftStore.getState();
848965
849966 store.setModelOptions(
850 threadId,
967 threadRef,
851968 providerModelOptions({
852969 codex: { fastMode: true },
853970 claudeAgent: { effort: "max" },
854971 }),
855972 );
856973
857 store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6"));
974 store.setModelSelection(threadRef, modelSelection("claudeAgent", "claude-opus-4-6"));
858975
859 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
976 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
860977 expect(draft?.modelSelectionByProvider.claudeAgent).toEqual(
861978 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
862979 );
......@@ -867,10 +984,10 @@ describe("composerDraftStore modelSelection", () => {
867984 it("creates the first sticky snapshot from provider option changes", () => {
868985 const store = useComposerDraftStore.getState();
869986
870 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4"));
987 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4"));
871988
872989 store.setProviderModelOptions(
873 threadId,
990 threadRef,
874991 "codex",
875992 {
876993 fastMode: true,
......@@ -892,12 +1009,12 @@ describe("composerDraftStore modelSelection", () => {
8921009 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
8931010 );
8941011 store.setModelSelection(
895 threadId,
1012 threadRef,
8961013 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
8971014 );
8981015
8991016 store.setProviderModelOptions(
900 threadId,
1017 threadRef,
9011018 "claudeAgent",
9021019 {
9031020 thinking: false,
......@@ -905,10 +1022,7 @@ describe("composerDraftStore modelSelection", () => {
9051022 { persistSticky: false },
9061023 );
9071024
908 expect(
909 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
910 .claudeAgent,
911 ).toEqual(
1025 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
9121026 modelSelection("claudeAgent", "claude-opus-4-6", {
9131027 thinking: false,
9141028 }),
......@@ -921,6 +1035,7 @@ describe("composerDraftStore modelSelection", () => {
9211035
9221036describe("composerDraftStore setModelSelection", () => {
9231037 const threadId = ThreadId.makeUnsafe("thread-model");
1038 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
9241039
9251040 beforeEach(() => {
9261041 resetComposerDraftStore();
......@@ -929,11 +1044,11 @@ describe("composerDraftStore setModelSelection", () => {
9291044 it("keeps explicit model overrides instead of coercing to null", () => {
9301045 const store = useComposerDraftStore.getState();
9311046
932 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.3-codex"));
1047 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.3-codex"));
9331048
934 expect(
935 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
936 ).toEqual(modelSelection("codex", "gpt-5.3-codex"));
1049 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
1050 modelSelection("codex", "gpt-5.3-codex"),
1051 );
9371052 });
9381053});
9391054
......@@ -975,11 +1090,12 @@ describe("composerDraftStore sticky composer settings", () => {
9751090 it("applies sticky activeProvider to new drafts", () => {
9761091 const store = useComposerDraftStore.getState();
9771092 const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider");
1093 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
9781094
9791095 store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6"));
980 store.applyStickyState(threadId);
1096 store.applyStickyState(threadRef);
9811097
982 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
1098 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({
9831099 modelSelectionByProvider: {
9841100 claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"),
9851101 },
......@@ -990,6 +1106,7 @@ describe("composerDraftStore sticky composer settings", () => {
9901106
9911107describe("composerDraftStore provider-scoped option updates", () => {
9921108 const threadId = ThreadId.makeUnsafe("thread-provider");
1109 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
9931110
9941111 beforeEach(() => {
9951112 resetComposerDraftStore();
......@@ -998,13 +1115,13 @@ describe("composerDraftStore provider-scoped option updates", () => {
9981115 it("retains off-provider option memory without changing the active selection", () => {
9991116 const store = useComposerDraftStore.getState();
10001117 store.setModelSelection(
1001 threadId,
1118 threadRef,
10021119 modelSelection("codex", "gpt-5.3-codex", {
10031120 reasoningEffort: "medium",
10041121 }),
10051122 );
1006 store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" });
1007 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
1123 store.setProviderModelOptions(threadRef, "claudeAgent", { effort: "max" });
1124 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
10081125 expect(draft?.modelSelectionByProvider.codex).toEqual(
10091126 modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }),
10101127 );
......@@ -1015,6 +1132,7 @@ describe("composerDraftStore provider-scoped option updates", () => {
10151132
10161133describe("composerDraftStore runtime and interaction settings", () => {
10171134 const threadId = ThreadId.makeUnsafe("thread-settings");
1135 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
10181136
10191137 beforeEach(() => {
10201138 resetComposerDraftStore();
......@@ -1023,32 +1141,28 @@ describe("composerDraftStore runtime and interaction settings", () => {
10231141 it("stores runtime mode overrides in the composer draft", () => {
10241142 const store = useComposerDraftStore.getState();
10251143
1026 store.setRuntimeMode(threadId, "approval-required");
1144 store.setRuntimeMode(threadRef, "approval-required");
10271145
1028 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.runtimeMode).toBe(
1029 "approval-required",
1030 );
1146 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.runtimeMode).toBe("approval-required");
10311147 });
10321148
10331149 it("stores interaction mode overrides in the composer draft", () => {
10341150 const store = useComposerDraftStore.getState();
10351151
1036 store.setInteractionMode(threadId, "plan");
1152 store.setInteractionMode(threadRef, "plan");
10371153
1038 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.interactionMode).toBe(
1039 "plan",
1040 );
1154 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.interactionMode).toBe("plan");
10411155 });
10421156
10431157 it("removes empty settings-only drafts when overrides are cleared", () => {
10441158 const store = useComposerDraftStore.getState();
10451159
1046 store.setRuntimeMode(threadId, "approval-required");
1047 store.setInteractionMode(threadId, "plan");
1048 store.setRuntimeMode(threadId, null);
1049 store.setInteractionMode(threadId, null);
1160 store.setRuntimeMode(threadRef, "approval-required");
1161 store.setInteractionMode(threadRef, "plan");
1162 store.setRuntimeMode(threadRef, null);
1163 store.setInteractionMode(threadRef, null);
10501164
1051 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
1165 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
10521166 });
10531167});
10541168
apps/web/src/composerDraftStore.ts+1572-948
......@@ -3,6 +3,7 @@ import {
33 type ClaudeCodeEffort,
44 type CodexReasoningEffort,
55 DEFAULT_MODEL_BY_PROVIDER,
6 type EnvironmentId,
67 ModelSelection,
78 ProjectId,
89 ProviderInteractionMode,
......@@ -10,8 +11,18 @@ import {
1011 ProviderModelOptions,
1112 RuntimeMode,
1213 type ServerProvider,
14 type ScopedProjectRef,
15 type ScopedThreadRef,
1316 ThreadId,
1417} from "@t3tools/contracts";
18import {
19 parseScopedProjectKey,
20 parseScopedThreadKey,
21 scopedProjectKey,
22 scopeProjectRef,
23 scopedThreadKey,
24 scopeThreadRef,
25} from "@t3tools/client-runtime";
1526import * as Schema from "effect/Schema";
1627import * as Equal from "effect/Equal";
1728import { DeepMutable } from "effect/Types";
......@@ -32,10 +43,13 @@ import { getDefaultServerModel } from "./providerModels";
3243import { UnifiedSettings } from "@t3tools/contracts/settings";
3344
3445export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
35const COMPOSER_DRAFT_STORAGE_VERSION = 3;
46const COMPOSER_DRAFT_STORAGE_VERSION = 5;
3647const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
3748export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
3849
50export const DraftId = Schema.String.pipe(Schema.brand("DraftId"));
51export type DraftId = typeof DraftId.Type;
52
3953const COMPOSER_PERSIST_DEBOUNCE_MS = 300;
4054
4155const composerDebouncedStorage = createDebouncedStorage(
......@@ -44,7 +58,7 @@ const composerDebouncedStorage = createDebouncedStorage(
4458);
4559
4660// Flush pending composer draft writes before page unload to prevent data loss.
47if (typeof window !== "undefined") {
61if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
4862 window.addEventListener("beforeunload", () => {
4963 composerDebouncedStorage.flush();
5064 });
......@@ -122,6 +136,14 @@ type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type;
122136type LegacyV2StoreFields = {
123137 stickyModelSelection?: ModelSelection | null;
124138 stickyModelOptions?: ProviderModelOptions | null;
139 projectDraftThreadIdByProjectId?: Record<string, string> | null;
140 draftsByThreadId?: Record<string, PersistedComposerThreadDraftState> | null;
141 draftThreadsByThreadId?: Record<string, PersistedDraftThreadState> | null;
142 projectDraftThreadIdByProjectKey?: Record<string, string> | null;
143 draftsByThreadKey?: Record<string, PersistedComposerThreadDraftState> | null;
144 draftThreadsByThreadKey?: Record<string, PersistedDraftThreadState> | null;
145 projectDraftThreadKeyByProjectKey?: Record<string, string> | null;
146 logicalProjectDraftThreadKeyByLogicalProjectKey?: Record<string, string> | null;
125147};
126148
127149type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState &
......@@ -129,20 +151,31 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState &
129151 LegacyV2StoreFields;
130152
131153const PersistedDraftThreadState = Schema.Struct({
154 threadId: ThreadId,
155 environmentId: Schema.String,
132156 projectId: ProjectId,
157 logicalProjectKey: Schema.optionalKey(Schema.String),
133158 createdAt: Schema.String,
134159 runtimeMode: RuntimeMode,
135160 interactionMode: ProviderInteractionMode,
136161 branch: Schema.NullOr(Schema.String),
137162 worktreePath: Schema.NullOr(Schema.String),
138163 envMode: DraftThreadEnvModeSchema,
164 promotedTo: Schema.optionalKey(
165 Schema.NullOr(
166 Schema.Struct({
167 environmentId: Schema.String,
168 threadId: Schema.String,
169 }),
170 ),
171 ),
139172});
140173type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type;
141174
142175const PersistedComposerDraftStoreState = Schema.Struct({
143 draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState),
144 draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState),
145 projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId),
176 draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState),
177 draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState),
178 logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String),
146179 stickyModelSelectionByProvider: Schema.optionalKey(
147180 Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)),
148181 ),
......@@ -155,6 +188,10 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({
155188 state: PersistedComposerDraftStoreState,
156189});
157190
191/**
192 * Composer content keyed by either a draft session (`DraftId`) or a real server
193 * thread (`ScopedThreadRef`). This is the editable payload shown in the composer.
194 */
158195export interface ComposerThreadDraftState {
159196 prompt: string;
160197 images: ComposerImageAttachment[];
......@@ -167,32 +204,94 @@ export interface ComposerThreadDraftState {
167204 interactionMode: ProviderInteractionMode | null;
168205}
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;
171216 projectId: ProjectId;
217 logicalProjectKey: string;
172218 createdAt: string;
173219 runtimeMode: RuntimeMode;
174220 interactionMode: ProviderInteractionMode;
175221 branch: string | null;
176222 worktreePath: string | null;
177223 envMode: DraftThreadEnvMode;
224 promotedTo?: ScopedThreadRef | null;
178225}
179226
180interface ProjectDraftThread extends DraftThreadState {
181 threadId: ThreadId;
227export type DraftThreadState = DraftSessionState;
228
229/**
230 * Draft session metadata paired with its stable draft-session identity.
231 */
232interface ProjectDraftSession extends DraftSessionState {
233 draftId: DraftId;
182234}
183235
236/**
237 * App-facing composer identity:
238 * - `DraftId` for pre-thread draft sessions
239 * - `ScopedThreadRef` for server-backed threads
240 *
241 * Raw `ThreadId` is intentionally excluded so callers cannot drop environment
242 * identity for real threads.
243 */
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 */
184253interface ComposerDraftStoreState {
185 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
186 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;
187 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
254 draftsByThreadKey: Record<string, ComposerThreadDraftState>;
255 draftThreadsByThreadKey: Record<string, DraftThreadState>;
256 logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>;
188257 stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
189258 stickyActiveProvider: ProviderKind | null;
190 getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null;
191 getDraftThread: (threadId: ThreadId) => DraftThreadState | null;
259 /** Returns the editable composer content for a draft session or server thread. */
260 getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null;
261 /** Looks up the active draft session for a logical project identity. */
262 getDraftThreadByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null;
263 getDraftSessionByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null;
264 getDraftThreadByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null;
265 getDraftSessionByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null;
266 /** Reads mutable draft-session metadata by `DraftId`. */
267 getDraftSession: (draftId: DraftId) => DraftSessionState | null;
268 /** Resolves a server-thread ref back to a matching draft session when one exists. */
269 getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null;
270 getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null;
271 getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null;
272 listDraftThreadKeys: () => string[];
273 hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean;
274 /** Creates or updates the draft session tracked for a logical project. */
275 setLogicalProjectDraftThreadId: (
276 logicalProjectKey: string,
277 projectRef: ScopedProjectRef,
278 draftId: DraftId,
279 options?: {
280 threadId?: ThreadId;
281 branch?: string | null;
282 worktreePath?: string | null;
283 createdAt?: string;
284 envMode?: DraftThreadEnvMode;
285 runtimeMode?: RuntimeMode;
286 interactionMode?: ProviderInteractionMode;
287 },
288 ) => void;
289 /** Creates or updates the draft session tracked for a concrete project ref. */
192290 setProjectDraftThreadId: (
193 projectId: ProjectId,
194 threadId: ThreadId,
291 projectRef: ScopedProjectRef,
292 draftId: DraftId,
195293 options?: {
294 threadId?: ThreadId;
196295 branch?: string | null;
197296 worktreePath?: string | null;
198297 createdAt?: string;
......@@ -201,65 +300,76 @@ interface ComposerDraftStoreState {
201300 interactionMode?: ProviderInteractionMode;
202301 },
203302 ) => void;
303 /** Updates mutable draft-session metadata without touching composer content. */
204304 setDraftThreadContext: (
205 threadId: ThreadId,
305 threadRef: ComposerThreadTarget,
206306 options: {
207307 branch?: string | null;
208308 worktreePath?: string | null;
209 projectId?: ProjectId;
309 projectRef?: ScopedProjectRef;
210310 createdAt?: string;
211311 envMode?: DraftThreadEnvMode;
212312 runtimeMode?: RuntimeMode;
213313 interactionMode?: ProviderInteractionMode;
214314 },
215315 ) => void;
216 clearProjectDraftThreadId: (projectId: ProjectId) => void;
217 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
218 clearDraftThread: (threadId: ThreadId) => void;
316 clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void;
317 clearProjectDraftThreadById: (
318 projectRef: ScopedProjectRef,
319 threadRef: ComposerThreadTarget,
320 ) => void;
321 /** Marks a draft session as being promoted to a real server thread. */
322 markDraftThreadPromoting: (threadRef: ComposerThreadTarget, promotedTo?: ScopedThreadRef) => void;
323 /** Removes draft-session metadata after promotion is complete. */
324 finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void;
325 clearDraftThread: (threadRef: ComposerThreadTarget) => void;
219326 setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void;
220 setPrompt: (threadId: ThreadId, prompt: string) => void;
221 setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;
327 setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void;
328 setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void;
222329 setModelSelection: (
223 threadId: ThreadId,
330 threadRef: ComposerThreadTarget,
224331 modelSelection: ModelSelection | null | undefined,
225332 ) => void;
226333 setModelOptions: (
227 threadId: ThreadId,
334 threadRef: ComposerThreadTarget,
228335 modelOptions: ProviderModelOptions | null | undefined,
229336 ) => void;
230 applyStickyState: (threadId: ThreadId) => void;
337 applyStickyState: (threadRef: ComposerThreadTarget) => void;
231338 setProviderModelOptions: (
232 threadId: ThreadId,
339 threadRef: ComposerThreadTarget,
233340 provider: ProviderKind,
234341 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
235342 options?: {
236343 persistSticky?: boolean;
237344 },
238345 ) => void;
239 setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void;
346 setRuntimeMode: (
347 threadRef: ComposerThreadTarget,
348 runtimeMode: RuntimeMode | null | undefined,
349 ) => void;
240350 setInteractionMode: (
241 threadId: ThreadId,
351 threadRef: ComposerThreadTarget,
242352 interactionMode: ProviderInteractionMode | null | undefined,
243353 ) => void;
244 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;
245 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;
246 removeImage: (threadId: ThreadId, imageId: string) => void;
354 addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void;
355 addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void;
356 removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void;
247357 insertTerminalContext: (
248 threadId: ThreadId,
358 threadRef: ComposerThreadTarget,
249359 prompt: string,
250360 context: TerminalContextDraft,
251361 index: number,
252362 ) => boolean;
253 addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void;
254 addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;
255 removeTerminalContext: (threadId: ThreadId, contextId: string) => void;
256 clearTerminalContexts: (threadId: ThreadId) => void;
257 clearPersistedAttachments: (threadId: ThreadId) => void;
363 addTerminalContext: (threadRef: ComposerThreadTarget, context: TerminalContextDraft) => void;
364 addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void;
365 removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void;
366 clearTerminalContexts: (threadRef: ComposerThreadTarget) => void;
367 clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void;
258368 syncPersistedAttachments: (
259 threadId: ThreadId,
369 threadRef: ComposerThreadTarget,
260370 attachments: PersistedComposerImageAttachment[],
261371 ) => void;
262 clearComposerContent: (threadId: ThreadId) => void;
372 clearComposerContent: (threadRef: ComposerThreadTarget) => void;
263373}
264374
265375export interface EffectiveComposerModelState {
......@@ -293,9 +403,9 @@ function modelSelectionByProviderToOptions(
293403}
294404
295405const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({
296 draftsByThreadId: {},
297 draftThreadsByThreadId: {},
298 projectDraftThreadIdByProjectId: {},
406 draftsByThreadKey: {},
407 draftThreadsByThreadKey: {},
408 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
299409 stickyModelSelectionByProvider: {},
300410 stickyActiveProvider: null,
301411});
......@@ -748,35 +858,323 @@ function normalizeDraftThreadEnvMode(
748858 return fallbackWorktreePath ? "worktree" : "local";
749859}
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
7511086function normalizePersistedDraftThreads(
7521087 rawDraftThreadsByThreadId: unknown,
753 rawProjectDraftThreadIdByProjectId: unknown,
1088 rawProjectDraftThreadIdByProjectKey: unknown,
7541089): Pick<
7551090 PersistedComposerDraftStoreState,
756 "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId"
1091 "draftThreadsByThreadKey" | "logicalProjectDraftThreadKeyByLogicalProjectKey"
7571092> {
758 const draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState> = {};
1093 const draftThreadsByThreadKey: Record<string, PersistedDraftThreadState> = {};
1094 const environmentIdByThreadId = new Map<ThreadId, EnvironmentId>();
1095 if (
1096 rawProjectDraftThreadIdByProjectKey &&
1097 typeof rawProjectDraftThreadIdByProjectKey === "object"
1098 ) {
1099 for (const [projectKey, threadId] of Object.entries(
1100 rawProjectDraftThreadIdByProjectKey as Record<string, unknown>,
1101 )) {
1102 if (typeof threadId !== "string" || threadId.length === 0) {
1103 continue;
1104 }
1105 const projectRef = parseScopedProjectKey(projectKey);
1106 if (!projectRef) {
1107 continue;
1108 }
1109 const parsedThreadRef = parseScopedThreadKey(threadId);
1110 if (parsedThreadRef) {
1111 environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId);
1112 continue;
1113 }
1114 environmentIdByThreadId.set(threadId as ThreadId, projectRef.environmentId);
1115 }
1116 }
7591117 if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") {
760 for (const [threadId, rawDraftThread] of Object.entries(
1118 for (const [threadKeyOrId, rawDraftThread] of Object.entries(
7611119 rawDraftThreadsByThreadId as Record<string, unknown>,
7621120 )) {
763 if (typeof threadId !== "string" || threadId.length === 0) {
1121 if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
7641122 continue;
7651123 }
7661124 if (!rawDraftThread || typeof rawDraftThread !== "object") {
7671125 continue;
7681126 }
7691127 const candidateDraftThread = rawDraftThread as Record<string, unknown>;
1128 const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
1129 const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId);
1130 const threadId =
1131 parsedThreadRef?.threadId ??
1132 (typeof candidateDraftThread.threadId === "string" &&
1133 candidateDraftThread.threadId.length > 0
1134 ? (candidateDraftThread.threadId as ThreadId)
1135 : (threadKeyOrId as ThreadId));
1136 const environmentId =
1137 parsedThreadRef?.environmentId ??
1138 (typeof candidateDraftThread.environmentId === "string" &&
1139 candidateDraftThread.environmentId.length > 0
1140 ? (candidateDraftThread.environmentId as EnvironmentId)
1141 : environmentIdByThreadId.get(threadKeyOrId as ThreadId));
7701142 const projectId = candidateDraftThread.projectId;
7711143 const createdAt = candidateDraftThread.createdAt;
7721144 const branch = candidateDraftThread.branch;
7731145 const worktreePath = candidateDraftThread.worktreePath;
7741146 const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null;
775 if (typeof projectId !== "string" || projectId.length === 0) {
1147 const promotedToCandidate = candidateDraftThread.promotedTo;
1148 const promotedToRecord =
1149 promotedToCandidate && typeof promotedToCandidate === "object"
1150 ? (promotedToCandidate as Record<string, unknown>)
1151 : null;
1152 const promotedTo =
1153 promotedToRecord &&
1154 typeof promotedToRecord.environmentId === "string" &&
1155 promotedToRecord.environmentId.length > 0 &&
1156 typeof promotedToRecord.threadId === "string" &&
1157 promotedToRecord.threadId.length > 0
1158 ? scopeThreadRef(
1159 promotedToRecord.environmentId as EnvironmentId,
1160 promotedToRecord.threadId as ThreadId,
1161 )
1162 : null;
1163 if (typeof projectId !== "string" || projectId.length === 0 || environmentId === undefined) {
7761164 continue;
7771165 }
778 draftThreadsByThreadId[threadId as ThreadId] = {
1166 const normalizedEnvironmentId = environmentId as EnvironmentId;
1167 draftThreadsByThreadKey[threadKey] = {
1168 threadId,
1169 environmentId: normalizedEnvironmentId,
7791170 projectId: projectId as ProjectId,
1171 logicalProjectKey:
1172 typeof candidateDraftThread.logicalProjectKey === "string" &&
1173 candidateDraftThread.logicalProjectKey.length > 0
1174 ? candidateDraftThread.logicalProjectKey
1175 : parsedThreadRef
1176 ? projectDraftKey(scopeProjectRef(normalizedEnvironmentId, projectId as ProjectId))
1177 : threadKeyOrId,
7801178 createdAt:
7811179 typeof createdAt === "string" && createdAt.length > 0
7821180 ? createdAt
......@@ -794,59 +1192,97 @@ function normalizePersistedDraftThreads(
7941192 branch: typeof branch === "string" ? branch : null,
7951193 worktreePath: normalizedWorktreePath,
7961194 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
1195 promotedTo,
7971196 };
7981197 }
7991198 }
8001199
801 const projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {};
1200 const logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> = {};
8021201 if (
803 rawProjectDraftThreadIdByProjectId &&
804 typeof rawProjectDraftThreadIdByProjectId === "object"
1202 rawProjectDraftThreadIdByProjectKey &&
1203 typeof rawProjectDraftThreadIdByProjectKey === "object"
8051204 ) {
806 for (const [projectId, threadId] of Object.entries(
807 rawProjectDraftThreadIdByProjectId as Record<string, unknown>,
1205 for (const [logicalProjectKey, threadKeyOrId] of Object.entries(
1206 rawProjectDraftThreadIdByProjectKey as Record<string, unknown>,
8081207 )) {
809 if (
810 typeof projectId === "string" &&
811 projectId.length > 0 &&
812 typeof threadId === "string" &&
813 threadId.length > 0
814 ) {
815 projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId;
816 if (!draftThreadsByThreadId[threadId as ThreadId]) {
817 draftThreadsByThreadId[threadId as ThreadId] = {
818 projectId: projectId as ProjectId,
819 createdAt: new Date().toISOString(),
820 runtimeMode: DEFAULT_RUNTIME_MODE,
821 interactionMode: DEFAULT_INTERACTION_MODE,
822 branch: null,
823 worktreePath: null,
824 envMode: "local",
825 };
826 } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) {
827 draftThreadsByThreadId[threadId as ThreadId] = {
828 ...draftThreadsByThreadId[threadId as ThreadId]!,
829 projectId: projectId as ProjectId,
1208 if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
1209 continue;
1210 }
1211 const projectRef = parseScopedProjectKey(logicalProjectKey);
1212 const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
1213 const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId);
1214 logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey;
1215 if (parsedThreadRef) {
1216 environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId);
1217 }
1218 if (!projectRef) {
1219 const existingDraftThread = draftThreadsByThreadKey[threadKey];
1220 if (existingDraftThread && !existingDraftThread.logicalProjectKey) {
1221 draftThreadsByThreadKey[threadKey] = {
1222 ...existingDraftThread,
1223 logicalProjectKey,
8301224 };
8311225 }
1226 continue;
1227 }
1228 if (!draftThreadsByThreadKey[threadKey]) {
1229 draftThreadsByThreadKey[threadKey] = {
1230 threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId),
1231 environmentId: projectRef.environmentId,
1232 projectId: projectRef.projectId,
1233 logicalProjectKey,
1234 createdAt: new Date().toISOString(),
1235 runtimeMode: DEFAULT_RUNTIME_MODE,
1236 interactionMode: DEFAULT_INTERACTION_MODE,
1237 branch: null,
1238 worktreePath: null,
1239 envMode: "local",
1240 promotedTo: null,
1241 };
1242 } else if (
1243 draftThreadsByThreadKey[threadKey]?.projectId !== projectRef.projectId ||
1244 draftThreadsByThreadKey[threadKey]?.environmentId !== projectRef.environmentId
1245 ) {
1246 draftThreadsByThreadKey[threadKey] = {
1247 ...draftThreadsByThreadKey[threadKey]!,
1248 threadId: draftThreadsByThreadKey[threadKey]!.threadId,
1249 environmentId: projectRef.environmentId,
1250 projectId: projectRef.projectId,
1251 logicalProjectKey,
1252 };
8321253 }
8331254 }
8341255 }
8351256
836 return { draftThreadsByThreadId, projectDraftThreadIdByProjectId };
1257 return { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey };
8371258}
8381259
8391260function normalizePersistedDraftsByThreadId(
8401261 rawDraftMap: unknown,
841): PersistedComposerDraftStoreState["draftsByThreadId"] {
1262 draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"],
1263): PersistedComposerDraftStoreState["draftsByThreadKey"] {
8421264 if (!rawDraftMap || typeof rawDraftMap !== "object") {
8431265 return {};
8441266 }
8451267
846 const nextDraftsByThreadId: DeepMutable<PersistedComposerDraftStoreState["draftsByThreadId"]> =
1268 const environmentIdByThreadId = new Map<ThreadId, EnvironmentId>();
1269 for (const [threadKey, draftThread] of Object.entries(draftThreadsByThreadKey)) {
1270 const parsedThreadRef = composerThreadRefFromKey(threadKey);
1271 if (!parsedThreadRef) {
1272 continue;
1273 }
1274 environmentIdByThreadId.set(
1275 parsedThreadRef.threadId,
1276 draftThread.environmentId as EnvironmentId,
1277 );
1278 }
1279
1280 const nextDraftsByThreadKey: DeepMutable<PersistedComposerDraftStoreState["draftsByThreadKey"]> =
8471281 {};
848 for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) {
849 if (typeof threadId !== "string" || threadId.length === 0) {
1282 for (const [threadKeyOrId, draftValue] of Object.entries(
1283 rawDraftMap as Record<string, unknown>,
1284 )) {
1285 if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
8501286 continue;
8511287 }
8521288 if (!draftValue || typeof draftValue !== "object") {
......@@ -937,7 +1373,19 @@ function normalizePersistedDraftsByThreadId(
9371373 ) {
9381374 continue;
9391375 }
940 nextDraftsByThreadId[threadId as ThreadId] = {
1376 const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
1377 const normalizedThreadKey =
1378 parsedThreadRef !== null
1379 ? normalizeLegacyComposerStorageKey(threadKeyOrId)
1380 : draftThreadsByThreadKey[threadKeyOrId] !== undefined
1381 ? threadKeyOrId
1382 : (() => {
1383 const environmentId = environmentIdByThreadId.get(threadKeyOrId as ThreadId);
1384 return environmentId
1385 ? normalizeLegacyComposerStorageKey(threadKeyOrId, { environmentId })
1386 : threadKeyOrId;
1387 })();
1388 nextDraftsByThreadKey[normalizedThreadKey] = {
9411389 prompt,
9421390 attachments,
9431391 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
......@@ -947,7 +1395,7 @@ function normalizePersistedDraftsByThreadId(
9471395 };
9481396 }
9491397
950 return nextDraftsByThreadId;
1398 return nextDraftsByThreadKey;
9511399}
9521400
9531401function migratePersistedComposerDraftStoreState(
......@@ -957,9 +1405,14 @@ function migratePersistedComposerDraftStoreState(
9571405 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
9581406 }
9591407 const candidate = persistedState as LegacyPersistedComposerDraftStoreState;
960 const rawDraftMap = candidate.draftsByThreadId;
961 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
962 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;
1408 const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId;
1409 const rawDraftThreadsByThreadId =
1410 candidate.draftThreadsByThreadKey ?? candidate.draftThreadsByThreadId;
1411 const rawProjectDraftThreadIdByProjectKey =
1412 candidate.logicalProjectDraftThreadKeyByLogicalProjectKey ??
1413 candidate.projectDraftThreadKeyByProjectKey ??
1414 candidate.projectDraftThreadIdByProjectKey ??
1415 candidate.projectDraftThreadIdByProjectId;
9631416
9641417 // Migrate sticky state from v2 (dual) to v3 (consolidated)
9651418 const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {};
......@@ -982,13 +1435,16 @@ function migratePersistedComposerDraftStoreState(
9821435 );
9831436 const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null;
9841437
985 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
986 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId);
987 const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap);
1438 const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } =
1439 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey);
1440 const draftsByThreadKey = normalizePersistedDraftsByThreadId(
1441 rawDraftMap,
1442 draftThreadsByThreadKey,
1443 );
9881444 return {
989 draftsByThreadId,
990 draftThreadsByThreadId,
991 projectDraftThreadIdByProjectId,
1445 draftsByThreadKey,
1446 draftThreadsByThreadKey,
1447 logicalProjectDraftThreadKeyByLogicalProjectKey,
9921448 stickyModelSelectionByProvider,
9931449 stickyActiveProvider,
9941450 };
......@@ -997,11 +1453,11 @@ function migratePersistedComposerDraftStoreState(
9971453function partializeComposerDraftStoreState(
9981454 state: ComposerDraftStoreState,
9991455): PersistedComposerDraftStoreState {
1000 const persistedDraftsByThreadId: DeepMutable<
1001 PersistedComposerDraftStoreState["draftsByThreadId"]
1456 const persistedDraftsByThreadKey: DeepMutable<
1457 PersistedComposerDraftStoreState["draftsByThreadKey"]
10021458 > = {};
1003 for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {
1004 if (typeof threadId !== "string" || threadId.length === 0) {
1459 for (const [threadKey, draft] of Object.entries(state.draftsByThreadKey)) {
1460 if (typeof threadKey !== "string" || threadKey.length === 0) {
10051461 continue;
10061462 }
10071463 const hasModelData =
......@@ -1041,12 +1497,13 @@ function partializeComposerDraftStoreState(
10411497 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),
10421498 ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}),
10431499 };
1044 persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;
1500 persistedDraftsByThreadKey[threadKey] = persistedDraft;
10451501 }
10461502 return {
1047 draftsByThreadId: persistedDraftsByThreadId,
1048 draftThreadsByThreadId: state.draftThreadsByThreadId,
1049 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
1503 draftsByThreadKey: persistedDraftsByThreadKey,
1504 draftThreadsByThreadKey: state.draftThreadsByThreadKey,
1505 logicalProjectDraftThreadKeyByLogicalProjectKey:
1506 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
10501507 stickyModelSelectionByProvider: state.stickyModelSelectionByProvider,
10511508 stickyActiveProvider: state.stickyActiveProvider,
10521509 };
......@@ -1059,10 +1516,14 @@ function normalizeCurrentPersistedComposerDraftStoreState(
10591516 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
10601517 }
10611518 const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState;
1062 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
1519 const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } =
10631520 normalizePersistedDraftThreads(
1064 normalizedPersistedState.draftThreadsByThreadId,
1065 normalizedPersistedState.projectDraftThreadIdByProjectId,
1521 normalizedPersistedState.draftThreadsByThreadKey ??
1522 normalizedPersistedState.draftThreadsByThreadId,
1523 normalizedPersistedState.logicalProjectDraftThreadKeyByLogicalProjectKey ??
1524 normalizedPersistedState.projectDraftThreadKeyByProjectKey ??
1525 normalizedPersistedState.projectDraftThreadIdByProjectKey ??
1526 normalizedPersistedState.projectDraftThreadIdByProjectId,
10661527 );
10671528
10681529 // Handle both v3 (modelSelectionByProvider) and v2/legacy formats
......@@ -1105,16 +1566,19 @@ function normalizeCurrentPersistedComposerDraftStoreState(
11051566 }
11061567
11071568 return {
1108 draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId),
1109 draftThreadsByThreadId,
1110 projectDraftThreadIdByProjectId,
1569 draftsByThreadKey: normalizePersistedDraftsByThreadId(
1570 normalizedPersistedState.draftsByThreadKey ?? normalizedPersistedState.draftsByThreadId,
1571 draftThreadsByThreadKey,
1572 ),
1573 draftThreadsByThreadKey,
1574 logicalProjectDraftThreadKeyByLogicalProjectKey,
11111575 stickyModelSelectionByProvider,
11121576 stickyActiveProvider,
11131577 };
11141578}
11151579
1116function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
1117 if (threadId.length === 0) {
1580function readPersistedAttachmentIdsFromStorage(threadKey: string): string[] {
1581 if (threadKey.length === 0) {
11181582 return [];
11191583 }
11201584 try {
......@@ -1125,7 +1589,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
11251589 if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) {
11261590 return [];
11271591 }
1128 return (persisted.state.draftsByThreadId[threadId]?.attachments ?? []).map(
1592 return (persisted.state.draftsByThreadKey[threadKey]?.attachments ?? []).map(
11291593 (attachment) => attachment.id,
11301594 );
11311595 } catch {
......@@ -1134,7 +1598,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
11341598}
11351599
11361600function verifyPersistedAttachments(
1137 threadId: ThreadId,
1601 threadKey: string,
11381602 attachments: PersistedComposerImageAttachment[],
11391603 set: (
11401604 partial:
......@@ -1149,12 +1613,12 @@ function verifyPersistedAttachments(
11491613 let persistedIdSet = new Set<string>();
11501614 try {
11511615 composerDebouncedStorage.flush();
1152 persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId));
1616 persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadKey));
11531617 } catch {
11541618 persistedIdSet = new Set();
11551619 }
11561620 set((state) => {
1157 const current = state.draftsByThreadId[threadId];
1621 const current = state.draftsByThreadKey[threadKey];
11581622 if (!current) {
11591623 return state;
11601624 }
......@@ -1170,13 +1634,13 @@ function verifyPersistedAttachments(
11701634 persistedAttachments,
11711635 nonPersistedImageIds,
11721636 };
1173 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1637 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
11741638 if (shouldRemoveDraft(nextDraft)) {
1175 delete nextDraftsByThreadId[threadId];
1639 delete nextDraftsByThreadKey[threadKey];
11761640 } else {
1177 nextDraftsByThreadId[threadId] = nextDraft;
1641 nextDraftsByThreadKey[threadKey] = nextDraft;
11781642 }
1179 return { draftsByThreadId: nextDraftsByThreadId };
1643 return { draftsByThreadKey: nextDraftsByThreadKey };
11801644 });
11811645}
11821646
......@@ -1258,879 +1722,980 @@ function toHydratedThreadDraft(
12581722 };
12591723}
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>()(
12621756 persist(
1263 (set, get) => ({
1264 draftsByThreadId: {},
1265 draftThreadsByThreadId: {},
1266 projectDraftThreadIdByProjectId: {},
1267 stickyModelSelectionByProvider: {},
1268 stickyActiveProvider: null,
1269 getDraftThreadByProjectId: (projectId) => {
1270 if (projectId.length === 0) {
1271 return null;
1272 }
1273 const threadId = get().projectDraftThreadIdByProjectId[projectId];
1274 if (!threadId) {
1275 return null;
1276 }
1277 const draftThread = get().draftThreadsByThreadId[threadId];
1278 if (!draftThread || draftThread.projectId !== projectId) {
1279 return null;
1280 }
1281 return {
1282 threadId,
1283 ...draftThread,
1284 };
1285 },
1286 getDraftThread: (threadId) => {
1287 if (threadId.length === 0) {
1288 return null;
1289 }
1290 return get().draftThreadsByThreadId[threadId] ?? null;
1291 },
1292 setProjectDraftThreadId: (projectId, threadId, options) => {
1293 if (projectId.length === 0 || threadId.length === 0) {
1294 return;
1295 }
1296 set((state) => {
1297 const existingThread = state.draftThreadsByThreadId[threadId];
1298 const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId];
1299 const nextWorktreePath =
1300 options?.worktreePath === undefined
1301 ? (existingThread?.worktreePath ?? null)
1302 : (options.worktreePath ?? null);
1303 const nextDraftThread: DraftThreadState = {
1304 projectId,
1305 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
1306 runtimeMode:
1307 options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
1308 interactionMode:
1309 options?.interactionMode ??
1310 existingThread?.interactionMode ??
1311 DEFAULT_INTERACTION_MODE,
1312 branch:
1313 options?.branch === undefined
1314 ? (existingThread?.branch ?? null)
1315 : (options.branch ?? null),
1316 worktreePath: nextWorktreePath,
1317 envMode:
1318 options?.envMode ??
1319 (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")),
1320 };
1321 const hasSameProjectMapping = previousThreadIdForProject === threadId;
1322 const hasSameDraftThread =
1323 existingThread &&
1324 existingThread.projectId === nextDraftThread.projectId &&
1325 existingThread.createdAt === nextDraftThread.createdAt &&
1326 existingThread.runtimeMode === nextDraftThread.runtimeMode &&
1327 existingThread.interactionMode === nextDraftThread.interactionMode &&
1328 existingThread.branch === nextDraftThread.branch &&
1329 existingThread.worktreePath === nextDraftThread.worktreePath &&
1330 existingThread.envMode === nextDraftThread.envMode;
1331 if (hasSameProjectMapping && hasSameDraftThread) {
1332 return state;
1333 }
1334 const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {
1335 ...state.projectDraftThreadIdByProjectId,
1336 [projectId]: threadId,
1337 };
1338 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
1339 ...state.draftThreadsByThreadId,
1340 [threadId]: nextDraftThread,
1341 };
1342 let nextDraftsByThreadId = state.draftsByThreadId;
1343 if (
1344 previousThreadIdForProject &&
1345 previousThreadIdForProject !== threadId &&
1346 !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject)
1347 ) {
1348 delete nextDraftThreadsByThreadId[previousThreadIdForProject];
1349 if (state.draftsByThreadId[previousThreadIdForProject] !== undefined) {
1350 nextDraftsByThreadId = { ...state.draftsByThreadId };
1351 delete nextDraftsByThreadId[previousThreadIdForProject];
1352 }
1353 }
1354 return {
1355 draftsByThreadId: nextDraftsByThreadId,
1356 draftThreadsByThreadId: nextDraftThreadsByThreadId,
1357 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
1358 };
1359 });
1360 },
1361 setDraftThreadContext: (threadId, options) => {
1362 if (threadId.length === 0) {
1363 return;
1364 }
1365 set((state) => {
1366 const existing = state.draftThreadsByThreadId[threadId];
1367 if (!existing) {
1368 return state;
1757 (setBase, get) => {
1758 const set = setBase;
1759
1760 return {
1761 draftsByThreadKey: {},
1762 draftThreadsByThreadKey: {},
1763 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
1764 stickyModelSelectionByProvider: {},
1765 stickyActiveProvider: null,
1766 getComposerDraft: (target) => getComposerDraftState(get(), target),
1767 getDraftThreadByLogicalProjectKey: (logicalProjectKey) => {
1768 return get().getDraftSessionByLogicalProjectKey(logicalProjectKey);
1769 },
1770 getDraftSessionByLogicalProjectKey: (logicalProjectKey) => {
1771 const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey);
1772 if (normalizedLogicalProjectKey.length === 0) {
1773 return null;
13691774 }
1370 const nextProjectId = options.projectId ?? existing.projectId;
1371 if (nextProjectId.length === 0) {
1372 return state;
1775 const draftId =
1776 get().logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey];
1777 if (!draftId) {
1778 return null;
13731779 }
1374 const nextWorktreePath =
1375 options.worktreePath === undefined
1376 ? existing.worktreePath
1377 : (options.worktreePath ?? null);
1378 const nextDraftThread: DraftThreadState = {
1379 projectId: nextProjectId,
1380 createdAt:
1381 options.createdAt === undefined
1382 ? existing.createdAt
1383 : options.createdAt || existing.createdAt,
1384 runtimeMode: options.runtimeMode ?? existing.runtimeMode,
1385 interactionMode: options.interactionMode ?? existing.interactionMode,
1386 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
1387 worktreePath: nextWorktreePath,
1388 envMode:
1389 options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
1390 };
1391 const isUnchanged =
1392 nextDraftThread.projectId === existing.projectId &&
1393 nextDraftThread.createdAt === existing.createdAt &&
1394 nextDraftThread.runtimeMode === existing.runtimeMode &&
1395 nextDraftThread.interactionMode === existing.interactionMode &&
1396 nextDraftThread.branch === existing.branch &&
1397 nextDraftThread.worktreePath === existing.worktreePath &&
1398 nextDraftThread.envMode === existing.envMode;
1399 if (isUnchanged) {
1400 return state;
1780 const draftThread = get().draftThreadsByThreadKey[draftId];
1781 if (!draftThread || isDraftThreadPromoting(draftThread)) {
1782 return null;
14011783 }
1402 const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {
1403 ...state.projectDraftThreadIdByProjectId,
1404 [nextProjectId]: threadId,
1405 };
1406 if (existing.projectId !== nextProjectId) {
1407 if (nextProjectDraftThreadIdByProjectId[existing.projectId] === threadId) {
1408 delete nextProjectDraftThreadIdByProjectId[existing.projectId];
1784 return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread);
1785 },
1786 getDraftThreadByProjectRef: (projectRef) => {
1787 return get().getDraftSessionByProjectRef(projectRef);
1788 },
1789 getDraftSessionByProjectRef: (projectRef) => {
1790 for (const [draftId, draftThread] of Object.entries(get().draftThreadsByThreadKey)) {
1791 if (isDraftThreadPromoting(draftThread)) {
1792 continue;
14091793 }
1410 }
1411 return {
1412 draftThreadsByThreadId: {
1413 ...state.draftThreadsByThreadId,
1414 [threadId]: nextDraftThread,
1415 },
1416 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
1417 };
1418 });
1419 },
1420 clearProjectDraftThreadId: (projectId) => {
1421 if (projectId.length === 0) {
1422 return;
1423 }
1424 set((state) => {
1425 const threadId = state.projectDraftThreadIdByProjectId[projectId];
1426 if (threadId === undefined) {
1427 return state;
1428 }
1429 const { [projectId]: _removed, ...restProjectMappingsRaw } =
1430 state.projectDraftThreadIdByProjectId;
1431 const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
1432 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
1433 ...state.draftThreadsByThreadId,
1434 };
1435 let nextDraftsByThreadId = state.draftsByThreadId;
1436 if (!Object.values(restProjectMappings).includes(threadId)) {
1437 delete nextDraftThreadsByThreadId[threadId];
1438 if (state.draftsByThreadId[threadId] !== undefined) {
1439 nextDraftsByThreadId = { ...state.draftsByThreadId };
1440 delete nextDraftsByThreadId[threadId];
1794 if (
1795 draftThread.projectId === projectRef.projectId &&
1796 draftThread.environmentId === projectRef.environmentId
1797 ) {
1798 return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread);
14411799 }
14421800 }
1443 return {
1444 draftsByThreadId: nextDraftsByThreadId,
1445 draftThreadsByThreadId: nextDraftThreadsByThreadId,
1446 projectDraftThreadIdByProjectId: restProjectMappings,
1447 };
1448 });
1449 },
1450 clearProjectDraftThreadById: (projectId, threadId) => {
1451 if (projectId.length === 0 || threadId.length === 0) {
1452 return;
1453 }
1454 set((state) => {
1455 if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) {
1456 return state;
1457 }
1458 const { [projectId]: _removed, ...restProjectMappingsRaw } =
1459 state.projectDraftThreadIdByProjectId;
1460 const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
1461 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
1462 ...state.draftThreadsByThreadId,
1463 };
1464 let nextDraftsByThreadId = state.draftsByThreadId;
1465 if (!Object.values(restProjectMappings).includes(threadId)) {
1466 delete nextDraftThreadsByThreadId[threadId];
1467 if (state.draftsByThreadId[threadId] !== undefined) {
1468 nextDraftsByThreadId = { ...state.draftsByThreadId };
1469 delete nextDraftsByThreadId[threadId];
1801 return null;
1802 },
1803 getDraftSession: (draftId) => get().draftThreadsByThreadKey[draftId] ?? null,
1804 getDraftSessionByRef: (threadRef) => {
1805 for (const draftSession of Object.values(get().draftThreadsByThreadKey)) {
1806 if (
1807 draftSession.environmentId === threadRef.environmentId &&
1808 draftSession.threadId === threadRef.threadId
1809 ) {
1810 return draftSession;
14701811 }
14711812 }
1472 return {
1473 draftsByThreadId: nextDraftsByThreadId,
1474 draftThreadsByThreadId: nextDraftThreadsByThreadId,
1475 projectDraftThreadIdByProjectId: restProjectMappings,
1476 };
1477 });
1478 },
1479 clearDraftThread: (threadId) => {
1480 if (threadId.length === 0) {
1481 return;
1482 }
1483 const existing = get().draftsByThreadId[threadId];
1484 if (existing) {
1485 for (const image of existing.images) {
1486 revokeObjectPreviewUrl(image.previewUrl);
1487 }
1488 }
1489 set((state) => {
1490 const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined;
1491 const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(
1492 threadId,
1493 );
1494 const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined;
1495 if (!hasDraftThread && !hasProjectMapping && !hasComposerDraft) {
1496 return state;
1497 }
1498 const nextProjectDraftThreadIdByProjectId = Object.fromEntries(
1499 Object.entries(state.projectDraftThreadIdByProjectId).filter(
1500 ([, draftThreadId]) => draftThreadId !== threadId,
1501 ),
1502 ) as Record<ProjectId, ThreadId>;
1503 const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } =
1504 state.draftThreadsByThreadId;
1505 const { [threadId]: _removedComposerDraft, ...restDraftsByThreadId } =
1506 state.draftsByThreadId;
1507 return {
1508 draftsByThreadId: restDraftsByThreadId,
1509 draftThreadsByThreadId: restDraftThreadsByThreadId,
1510 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
1511 };
1512 });
1513 },
1514 setStickyModelSelection: (modelSelection) => {
1515 const normalized = normalizeModelSelection(modelSelection);
1516 set((state) => {
1517 if (!normalized) {
1518 return state;
1813 return null;
1814 },
1815 getDraftThread: (threadRef) => {
1816 if (typeof threadRef === "string") {
1817 return get().getDraftSession(DraftId.makeUnsafe(threadRef));
15191818 }
1520 const nextMap: Partial<Record<ProviderKind, ModelSelection>> = {
1521 ...state.stickyModelSelectionByProvider,
1522 [normalized.provider]: normalized,
1523 };
1524 if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {
1525 return state.stickyActiveProvider === normalized.provider
1526 ? state
1527 : { stickyActiveProvider: normalized.provider };
1819 return get().getDraftSessionByRef(threadRef);
1820 },
1821 getDraftThreadByRef: (threadRef) => {
1822 return get().getDraftSessionByRef(threadRef);
1823 },
1824 listDraftThreadKeys: () =>
1825 Object.values(get().draftThreadsByThreadKey).map((draftThread) =>
1826 scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)),
1827 ),
1828 hasDraftThreadsInEnvironment: (environmentId) =>
1829 Object.values(get().draftThreadsByThreadKey).some(
1830 (draftThread) => draftThread.environmentId === environmentId,
1831 ),
1832 setLogicalProjectDraftThreadId: (logicalProjectKey, projectRef, draftId, options) => {
1833 const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey);
1834 if (normalizedLogicalProjectKey.length === 0 || draftId.length === 0) {
1835 return;
15281836 }
1529 return {
1530 stickyModelSelectionByProvider: nextMap,
1531 stickyActiveProvider: normalized.provider,
1532 };
1533 });
1534 },
1535 applyStickyState: (threadId) => {
1536 if (threadId.length === 0) {
1537 return;
1538 }
1539 set((state) => {
1540 const stickyMap = state.stickyModelSelectionByProvider;
1541 const stickyActiveProvider = state.stickyActiveProvider;
1542 if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) {
1543 return state;
1837 set((state) => {
1838 const existingThread = state.draftThreadsByThreadKey[draftId];
1839 const previousThreadKeyForLogicalProject =
1840 state.logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey];
1841 const nextDraftThread = createDraftThreadState(
1842 projectRef,
1843 options?.threadId ?? existingThread?.threadId ?? ThreadId.makeUnsafe(draftId),
1844 normalizedLogicalProjectKey,
1845 existingThread,
1846 options,
1847 );
1848 const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId;
1849 if (hasSameLogicalMapping && draftThreadsEqual(existingThread, nextDraftThread)) {
1850 return state;
1851 }
1852 const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> = {
1853 ...state.logicalProjectDraftThreadKeyByLogicalProjectKey,
1854 [normalizedLogicalProjectKey]: draftId,
1855 };
1856 const nextDraftThreadsByThreadKey: Record<string, DraftThreadState> = {
1857 ...state.draftThreadsByThreadKey,
1858 [draftId]: nextDraftThread,
1859 };
1860 let nextDraftsByThreadKey = state.draftsByThreadKey;
1861 if (
1862 previousThreadKeyForLogicalProject &&
1863 previousThreadKeyForLogicalProject !== draftId &&
1864 !isComposerThreadKeyInUse(
1865 nextLogicalProjectDraftThreadKeyByLogicalProjectKey,
1866 previousThreadKeyForLogicalProject,
1867 )
1868 ) {
1869 delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
1870 if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) {
1871 nextDraftsByThreadKey = { ...state.draftsByThreadKey };
1872 delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject];
1873 }
1874 }
1875 return {
1876 draftsByThreadKey: nextDraftsByThreadKey,
1877 draftThreadsByThreadKey: nextDraftThreadsByThreadKey,
1878 logicalProjectDraftThreadKeyByLogicalProjectKey:
1879 nextLogicalProjectDraftThreadKeyByLogicalProjectKey,
1880 };
1881 });
1882 },
1883 setProjectDraftThreadId: (projectRef, draftId, options) => {
1884 get().setLogicalProjectDraftThreadId(
1885 projectDraftKey(projectRef),
1886 projectRef,
1887 draftId,
1888 options,
1889 );
1890 },
1891 setDraftThreadContext: (threadRef, options) => {
1892 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
1893 if (threadKey.length === 0) {
1894 return;
15441895 }
1545 const existing = state.draftsByThreadId[threadId];
1546 const base = existing ?? createEmptyThreadDraft();
1547 const nextMap = { ...base.modelSelectionByProvider };
1548 for (const [provider, selection] of Object.entries(stickyMap)) {
1549 if (selection) {
1550 const current = nextMap[provider as ProviderKind];
1551 nextMap[provider as ProviderKind] = {
1552 ...selection,
1553 model: current?.model ?? selection.model,
1554 };
1896 set((state) => {
1897 const existing = state.draftThreadsByThreadKey[threadKey];
1898 if (!existing) {
1899 return state;
15551900 }
1901 const nextProjectRef = options.projectRef ?? {
1902 environmentId: existing.environmentId,
1903 projectId: existing.projectId,
1904 };
1905 if (
1906 nextProjectRef.projectId.length === 0 ||
1907 nextProjectRef.environmentId.length === 0
1908 ) {
1909 return state;
1910 }
1911 const nextWorktreePath =
1912 options.worktreePath === undefined
1913 ? existing.worktreePath
1914 : (options.worktreePath ?? null);
1915 const nextDraftThread: DraftThreadState = {
1916 threadId: existing.threadId,
1917 environmentId: nextProjectRef.environmentId,
1918 projectId: nextProjectRef.projectId,
1919 logicalProjectKey: existing.logicalProjectKey,
1920 createdAt:
1921 options.createdAt === undefined
1922 ? existing.createdAt
1923 : options.createdAt || existing.createdAt,
1924 runtimeMode: options.runtimeMode ?? existing.runtimeMode,
1925 interactionMode: options.interactionMode ?? existing.interactionMode,
1926 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
1927 worktreePath: nextWorktreePath,
1928 envMode:
1929 options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
1930 promotedTo: existing.promotedTo ?? null,
1931 };
1932 const isUnchanged =
1933 nextDraftThread.environmentId === existing.environmentId &&
1934 nextDraftThread.projectId === existing.projectId &&
1935 nextDraftThread.logicalProjectKey === existing.logicalProjectKey &&
1936 nextDraftThread.createdAt === existing.createdAt &&
1937 nextDraftThread.runtimeMode === existing.runtimeMode &&
1938 nextDraftThread.interactionMode === existing.interactionMode &&
1939 nextDraftThread.branch === existing.branch &&
1940 nextDraftThread.worktreePath === existing.worktreePath &&
1941 nextDraftThread.envMode === existing.envMode &&
1942 scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo);
1943 if (isUnchanged) {
1944 return state;
1945 }
1946 return {
1947 draftThreadsByThreadKey: {
1948 ...state.draftThreadsByThreadKey,
1949 [threadKey]: nextDraftThread,
1950 },
1951 };
1952 });
1953 },
1954 clearProjectDraftThreadId: (projectRef) => {
1955 set((state) => {
1956 const matchingThreadEntry = Object.entries(state.draftThreadsByThreadKey).find(
1957 ([, draftThread]) =>
1958 draftThread.projectId === projectRef.projectId &&
1959 draftThread.environmentId === projectRef.environmentId,
1960 );
1961 if (!matchingThreadEntry) {
1962 return state;
1963 }
1964 return removeDraftThreadReferences(state, matchingThreadEntry[0]);
1965 });
1966 },
1967 clearProjectDraftThreadById: (projectRef, threadRef) => {
1968 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
1969 if (threadKey.length === 0) {
1970 return;
15561971 }
1557 if (
1558 Equal.equals(base.modelSelectionByProvider, nextMap) &&
1559 base.activeProvider === stickyActiveProvider
1560 ) {
1561 return state;
1972 set((state) => {
1973 const draftThread = state.draftThreadsByThreadKey[threadKey];
1974 if (
1975 !draftThread ||
1976 draftThread.projectId !== projectRef.projectId ||
1977 draftThread.environmentId !== projectRef.environmentId
1978 ) {
1979 return state;
1980 }
1981 return removeDraftThreadReferences(state, threadKey);
1982 });
1983 },
1984 markDraftThreadPromoting: (threadRef, promotedTo) => {
1985 const threadKey = resolveComposerDraftKey(get(), threadRef);
1986 if (!threadKey) {
1987 return;
15621988 }
1563 const nextDraft: ComposerThreadDraftState = {
1564 ...base,
1565 modelSelectionByProvider: nextMap,
1566 activeProvider: stickyActiveProvider,
1567 };
1568 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1569 if (shouldRemoveDraft(nextDraft)) {
1570 delete nextDraftsByThreadId[threadId];
1571 } else {
1572 nextDraftsByThreadId[threadId] = nextDraft;
1989 set((state) => {
1990 const existing = state.draftThreadsByThreadKey[threadKey];
1991 if (!existing) {
1992 return state;
1993 }
1994 const nextPromotedTo =
1995 promotedTo ?? scopeThreadRef(existing.environmentId, existing.threadId);
1996 if (scopedThreadRefsEqual(existing.promotedTo, nextPromotedTo)) {
1997 return state;
1998 }
1999 return {
2000 draftThreadsByThreadKey: {
2001 ...state.draftThreadsByThreadKey,
2002 [threadKey]: {
2003 ...existing,
2004 promotedTo: nextPromotedTo,
2005 },
2006 },
2007 };
2008 });
2009 },
2010 finalizePromotedDraftThread: (threadRef) => {
2011 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2012 if (threadKey.length === 0) {
2013 return;
15732014 }
1574 return { draftsByThreadId: nextDraftsByThreadId };
1575 });
1576 },
1577 setPrompt: (threadId, prompt) => {
1578 if (threadId.length === 0) {
1579 return;
1580 }
1581 set((state) => {
1582 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1583 const nextDraft: ComposerThreadDraftState = {
1584 ...existing,
1585 prompt,
1586 };
1587 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1588 if (shouldRemoveDraft(nextDraft)) {
1589 delete nextDraftsByThreadId[threadId];
1590 } else {
1591 nextDraftsByThreadId[threadId] = nextDraft;
2015 set((state) => {
2016 const existing = state.draftThreadsByThreadKey[threadKey];
2017 if (!isDraftThreadPromoting(existing)) {
2018 return state;
2019 }
2020 return removeDraftThreadReferences(state, threadKey);
2021 });
2022 },
2023 clearDraftThread: (threadRef) => {
2024 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2025 if (threadKey.length === 0) {
2026 return;
15922027 }
1593 return { draftsByThreadId: nextDraftsByThreadId };
1594 });
1595 },
1596 setTerminalContexts: (threadId, contexts) => {
1597 if (threadId.length === 0) {
1598 return;
1599 }
1600 const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts);
1601 set((state) => {
1602 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1603 const nextDraft: ComposerThreadDraftState = {
1604 ...existing,
1605 prompt: ensureInlineTerminalContextPlaceholders(
1606 existing.prompt,
1607 normalizedContexts.length,
1608 ),
1609 terminalContexts: normalizedContexts,
1610 };
1611 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1612 if (shouldRemoveDraft(nextDraft)) {
1613 delete nextDraftsByThreadId[threadId];
1614 } else {
1615 nextDraftsByThreadId[threadId] = nextDraft;
2028 const existing = get().draftsByThreadKey[threadKey];
2029 if (existing) {
2030 for (const image of existing.images) {
2031 revokeObjectPreviewUrl(image.previewUrl);
2032 }
16162033 }
1617 return { draftsByThreadId: nextDraftsByThreadId };
1618 });
1619 },
1620 setModelSelection: (threadId, modelSelection) => {
1621 if (threadId.length === 0) {
1622 return;
1623 }
1624 const normalized = normalizeModelSelection(modelSelection);
1625 set((state) => {
1626 const existing = state.draftsByThreadId[threadId];
1627 if (!existing && normalized === null) {
1628 return state;
2034 set((state) => {
2035 const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined;
2036 const hasLogicalProjectMapping = Object.values(
2037 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
2038 ).includes(threadKey);
2039 const hasComposerDraft = state.draftsByThreadKey[threadKey] !== undefined;
2040 if (!hasDraftThread && !hasLogicalProjectMapping && !hasComposerDraft) {
2041 return state;
2042 }
2043 return removeDraftThreadReferences(state, threadKey);
2044 });
2045 },
2046 setStickyModelSelection: (modelSelection) => {
2047 const normalized = normalizeModelSelection(modelSelection);
2048 set((state) => {
2049 if (!normalized) {
2050 return state;
2051 }
2052 const nextMap: Partial<Record<ProviderKind, ModelSelection>> = {
2053 ...state.stickyModelSelectionByProvider,
2054 [normalized.provider]: normalized,
2055 };
2056 if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {
2057 return state.stickyActiveProvider === normalized.provider
2058 ? state
2059 : { stickyActiveProvider: normalized.provider };
2060 }
2061 return {
2062 stickyModelSelectionByProvider: nextMap,
2063 stickyActiveProvider: normalized.provider,
2064 };
2065 });
2066 },
2067 applyStickyState: (threadRef) => {
2068 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2069 if (threadKey.length === 0) {
2070 return;
16292071 }
1630 const base = existing ?? createEmptyThreadDraft();
1631 const nextMap = { ...base.modelSelectionByProvider };
1632 if (normalized) {
1633 const current = nextMap[normalized.provider];
1634 if (normalized.options !== undefined) {
1635 // Explicit options provided → use them
1636 nextMap[normalized.provider] = normalized;
2072 set((state) => {
2073 const stickyMap = state.stickyModelSelectionByProvider;
2074 const stickyActiveProvider = state.stickyActiveProvider;
2075 if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) {
2076 return state;
2077 }
2078 const existing = state.draftsByThreadKey[threadKey];
2079 const base = existing ?? createEmptyThreadDraft();
2080 const nextMap = { ...base.modelSelectionByProvider };
2081 for (const [provider, selection] of Object.entries(stickyMap)) {
2082 if (selection) {
2083 const current = nextMap[provider as ProviderKind];
2084 nextMap[provider as ProviderKind] = {
2085 ...selection,
2086 model: current?.model ?? selection.model,
2087 };
2088 }
2089 }
2090 if (
2091 Equal.equals(base.modelSelectionByProvider, nextMap) &&
2092 base.activeProvider === stickyActiveProvider
2093 ) {
2094 return state;
2095 }
2096 const nextDraft: ComposerThreadDraftState = {
2097 ...base,
2098 modelSelectionByProvider: nextMap,
2099 activeProvider: stickyActiveProvider,
2100 };
2101 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2102 if (shouldRemoveDraft(nextDraft)) {
2103 delete nextDraftsByThreadKey[threadKey];
16372104 } else {
1638 // No options in selection → preserve existing options, update provider+model
1639 nextMap[normalized.provider] = {
1640 provider: normalized.provider,
1641 model: normalized.model,
1642 ...(current?.options ? { options: current.options } : {}),
1643 };
2105 nextDraftsByThreadKey[threadKey] = nextDraft;
16442106 }
2107 return { draftsByThreadKey: nextDraftsByThreadKey };
2108 });
2109 },
2110 setPrompt: (threadRef, prompt) => {
2111 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2112 if (threadKey.length === 0) {
2113 return;
16452114 }
1646 const nextActiveProvider = normalized?.provider ?? base.activeProvider;
1647 if (
1648 Equal.equals(base.modelSelectionByProvider, nextMap) &&
1649 base.activeProvider === nextActiveProvider
1650 ) {
1651 return state;
1652 }
1653 const nextDraft: ComposerThreadDraftState = {
1654 ...base,
1655 modelSelectionByProvider: nextMap,
1656 activeProvider: nextActiveProvider,
1657 };
1658 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1659 if (shouldRemoveDraft(nextDraft)) {
1660 delete nextDraftsByThreadId[threadId];
1661 } else {
1662 nextDraftsByThreadId[threadId] = nextDraft;
1663 }
1664 return { draftsByThreadId: nextDraftsByThreadId };
1665 });
1666 },
1667 setModelOptions: (threadId, modelOptions) => {
1668 if (threadId.length === 0) {
1669 return;
1670 }
1671 const normalizedOpts = normalizeProviderModelOptions(modelOptions);
1672 set((state) => {
1673 const existing = state.draftsByThreadId[threadId];
1674 if (!existing && normalizedOpts === null) {
1675 return state;
1676 }
1677 const base = existing ?? createEmptyThreadDraft();
1678 const nextMap = { ...base.modelSelectionByProvider };
1679 for (const provider of ["codex", "claudeAgent"] as const) {
1680 // Only touch providers explicitly present in the input
1681 if (!normalizedOpts || !(provider in normalizedOpts)) continue;
1682 const opts = normalizedOpts[provider];
1683 const current = nextMap[provider];
1684 if (opts) {
1685 nextMap[provider] = {
1686 provider,
1687 model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
1688 options: opts,
1689 };
1690 } else if (current?.options) {
1691 // Remove options but keep the selection
1692 const { options: _, ...rest } = current;
1693 nextMap[provider] = rest as ModelSelection;
2115 set((state) => {
2116 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2117 const nextDraft: ComposerThreadDraftState = {
2118 ...existing,
2119 prompt,
2120 };
2121 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2122 if (shouldRemoveDraft(nextDraft)) {
2123 delete nextDraftsByThreadKey[threadKey];
2124 } else {
2125 nextDraftsByThreadKey[threadKey] = nextDraft;
16942126 }
2127 return { draftsByThreadKey: nextDraftsByThreadKey };
2128 });
2129 },
2130 setTerminalContexts: (threadRef, contexts) => {
2131 const threadKey = resolveComposerDraftKey(get(), threadRef);
2132 const threadId = resolveComposerThreadId(get(), threadRef);
2133 if (!threadKey || !threadId) {
2134 return;
16952135 }
1696 if (Equal.equals(base.modelSelectionByProvider, nextMap)) {
1697 return state;
2136 const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts);
2137 set((state) => {
2138 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2139 const nextDraft: ComposerThreadDraftState = {
2140 ...existing,
2141 prompt: ensureInlineTerminalContextPlaceholders(
2142 existing.prompt,
2143 normalizedContexts.length,
2144 ),
2145 terminalContexts: normalizedContexts,
2146 };
2147 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2148 if (shouldRemoveDraft(nextDraft)) {
2149 delete nextDraftsByThreadKey[threadKey];
2150 } else {
2151 nextDraftsByThreadKey[threadKey] = nextDraft;
2152 }
2153 return { draftsByThreadKey: nextDraftsByThreadKey };
2154 });
2155 },
2156 setModelSelection: (threadRef, modelSelection) => {
2157 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2158 if (threadKey.length === 0) {
2159 return;
16982160 }
1699 const nextDraft: ComposerThreadDraftState = {
1700 ...base,
1701 modelSelectionByProvider: nextMap,
1702 };
1703 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1704 if (shouldRemoveDraft(nextDraft)) {
1705 delete nextDraftsByThreadId[threadId];
1706 } else {
1707 nextDraftsByThreadId[threadId] = nextDraft;
2161 const normalized = normalizeModelSelection(modelSelection);
2162 set((state) => {
2163 const existing = state.draftsByThreadKey[threadKey];
2164 if (!existing && normalized === null) {
2165 return state;
2166 }
2167 const base = existing ?? createEmptyThreadDraft();
2168 const nextMap = { ...base.modelSelectionByProvider };
2169 if (normalized) {
2170 const current = nextMap[normalized.provider];
2171 if (normalized.options !== undefined) {
2172 // Explicit options provided → use them
2173 nextMap[normalized.provider] = normalized;
2174 } else {
2175 // No options in selection → preserve existing options, update provider+model
2176 nextMap[normalized.provider] = {
2177 provider: normalized.provider,
2178 model: normalized.model,
2179 ...(current?.options ? { options: current.options } : {}),
2180 };
2181 }
2182 }
2183 const nextActiveProvider = normalized?.provider ?? base.activeProvider;
2184 if (
2185 Equal.equals(base.modelSelectionByProvider, nextMap) &&
2186 base.activeProvider === nextActiveProvider
2187 ) {
2188 return state;
2189 }
2190 const nextDraft: ComposerThreadDraftState = {
2191 ...base,
2192 modelSelectionByProvider: nextMap,
2193 activeProvider: nextActiveProvider,
2194 };
2195 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2196 if (shouldRemoveDraft(nextDraft)) {
2197 delete nextDraftsByThreadKey[threadKey];
2198 } else {
2199 nextDraftsByThreadKey[threadKey] = nextDraft;
2200 }
2201 return { draftsByThreadKey: nextDraftsByThreadKey };
2202 });
2203 },
2204 setModelOptions: (threadRef, modelOptions) => {
2205 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2206 if (threadKey.length === 0) {
2207 return;
17082208 }
1709 return { draftsByThreadId: nextDraftsByThreadId };
1710 });
1711 },
1712 setProviderModelOptions: (threadId, provider, nextProviderOptions, options) => {
1713 if (threadId.length === 0) {
1714 return;
1715 }
1716 const normalizedProvider = normalizeProviderKind(provider);
1717 if (normalizedProvider === null) {
1718 return;
1719 }
1720 // Normalize just this provider's options
1721 const normalizedOpts = normalizeProviderModelOptions(
1722 { [normalizedProvider]: nextProviderOptions },
1723 normalizedProvider,
1724 );
1725 const providerOpts = normalizedOpts?.[normalizedProvider];
1726
1727 set((state) => {
1728 const existing = state.draftsByThreadId[threadId];
1729 const base = existing ?? createEmptyThreadDraft();
1730
1731 // Update the map entry for this provider
1732 const nextMap = { ...base.modelSelectionByProvider };
1733 const currentForProvider = nextMap[normalizedProvider];
1734 if (providerOpts) {
1735 nextMap[normalizedProvider] = {
1736 provider: normalizedProvider,
1737 model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
1738 options: providerOpts,
2209 const normalizedOpts = normalizeProviderModelOptions(modelOptions);
2210 set((state) => {
2211 const existing = state.draftsByThreadKey[threadKey];
2212 if (!existing && normalizedOpts === null) {
2213 return state;
2214 }
2215 const base = existing ?? createEmptyThreadDraft();
2216 const nextMap = { ...base.modelSelectionByProvider };
2217 for (const provider of ["codex", "claudeAgent"] as const) {
2218 // Only touch providers explicitly present in the input
2219 if (!normalizedOpts || !(provider in normalizedOpts)) continue;
2220 const opts = normalizedOpts[provider];
2221 const current = nextMap[provider];
2222 if (opts) {
2223 nextMap[provider] = {
2224 provider,
2225 model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
2226 options: opts,
2227 };
2228 } else if (current?.options) {
2229 // Remove options but keep the selection
2230 const { options: _, ...rest } = current;
2231 nextMap[provider] = rest as ModelSelection;
2232 }
2233 }
2234 if (Equal.equals(base.modelSelectionByProvider, nextMap)) {
2235 return state;
2236 }
2237 const nextDraft: ComposerThreadDraftState = {
2238 ...base,
2239 modelSelectionByProvider: nextMap,
17392240 };
1740 } else if (currentForProvider?.options) {
1741 const { options: _, ...rest } = currentForProvider;
1742 nextMap[normalizedProvider] = rest as ModelSelection;
2241 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2242 if (shouldRemoveDraft(nextDraft)) {
2243 delete nextDraftsByThreadKey[threadKey];
2244 } else {
2245 nextDraftsByThreadKey[threadKey] = nextDraft;
2246 }
2247 return { draftsByThreadKey: nextDraftsByThreadKey };
2248 });
2249 },
2250 setProviderModelOptions: (threadRef, provider, nextProviderOptions, options) => {
2251 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2252 if (threadKey.length === 0) {
2253 return;
17432254 }
2255 const normalizedProvider = normalizeProviderKind(provider);
2256 if (normalizedProvider === null) {
2257 return;
2258 }
2259 // Normalize just this provider's options
2260 const normalizedOpts = normalizeProviderModelOptions(
2261 { [normalizedProvider]: nextProviderOptions },
2262 normalizedProvider,
2263 );
2264 const providerOpts = normalizedOpts?.[normalizedProvider];
17442265
1745 // Handle sticky persistence
1746 let nextStickyMap = state.stickyModelSelectionByProvider;
1747 let nextStickyActiveProvider = state.stickyActiveProvider;
1748 if (options?.persistSticky === true) {
1749 nextStickyMap = { ...state.stickyModelSelectionByProvider };
1750 const stickyBase =
1751 nextStickyMap[normalizedProvider] ??
1752 base.modelSelectionByProvider[normalizedProvider] ??
1753 ({
1754 provider: normalizedProvider,
1755 model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
1756 } as ModelSelection);
2266 set((state) => {
2267 const existing = state.draftsByThreadKey[threadKey];
2268 const base = existing ?? createEmptyThreadDraft();
2269
2270 // Update the map entry for this provider
2271 const nextMap = { ...base.modelSelectionByProvider };
2272 const currentForProvider = nextMap[normalizedProvider];
17572273 if (providerOpts) {
1758 nextStickyMap[normalizedProvider] = {
1759 ...stickyBase,
2274 nextMap[normalizedProvider] = {
17602275 provider: normalizedProvider,
2276 model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
17612277 options: providerOpts,
17622278 };
1763 } else if (stickyBase.options) {
1764 const { options: _, ...rest } = stickyBase;
1765 nextStickyMap[normalizedProvider] = rest as ModelSelection;
2279 } else if (currentForProvider?.options) {
2280 const { options: _, ...rest } = currentForProvider;
2281 nextMap[normalizedProvider] = rest as ModelSelection;
17662282 }
1767 nextStickyActiveProvider = base.activeProvider ?? normalizedProvider;
1768 }
17692283
1770 if (
1771 Equal.equals(base.modelSelectionByProvider, nextMap) &&
1772 Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) &&
1773 state.stickyActiveProvider === nextStickyActiveProvider
1774 ) {
1775 return state;
1776 }
2284 // Handle sticky persistence
2285 let nextStickyMap = state.stickyModelSelectionByProvider;
2286 let nextStickyActiveProvider = state.stickyActiveProvider;
2287 if (options?.persistSticky === true) {
2288 nextStickyMap = { ...state.stickyModelSelectionByProvider };
2289 const stickyBase =
2290 nextStickyMap[normalizedProvider] ??
2291 base.modelSelectionByProvider[normalizedProvider] ??
2292 ({
2293 provider: normalizedProvider,
2294 model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
2295 } as ModelSelection);
2296 if (providerOpts) {
2297 nextStickyMap[normalizedProvider] = {
2298 ...stickyBase,
2299 provider: normalizedProvider,
2300 options: providerOpts,
2301 };
2302 } else if (stickyBase.options) {
2303 const { options: _, ...rest } = stickyBase;
2304 nextStickyMap[normalizedProvider] = rest as ModelSelection;
2305 }
2306 nextStickyActiveProvider = base.activeProvider ?? normalizedProvider;
2307 }
17772308
1778 const nextDraft: ComposerThreadDraftState = {
1779 ...base,
1780 modelSelectionByProvider: nextMap,
1781 };
1782 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1783 if (shouldRemoveDraft(nextDraft)) {
1784 delete nextDraftsByThreadId[threadId];
1785 } else {
1786 nextDraftsByThreadId[threadId] = nextDraft;
1787 }
2309 if (
2310 Equal.equals(base.modelSelectionByProvider, nextMap) &&
2311 Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) &&
2312 state.stickyActiveProvider === nextStickyActiveProvider
2313 ) {
2314 return state;
2315 }
17882316
1789 return {
1790 draftsByThreadId: nextDraftsByThreadId,
1791 ...(options?.persistSticky === true
1792 ? {
1793 stickyModelSelectionByProvider: nextStickyMap,
1794 stickyActiveProvider: nextStickyActiveProvider,
1795 }
1796 : {}),
1797 };
1798 });
1799 },
1800 setRuntimeMode: (threadId, runtimeMode) => {
1801 if (threadId.length === 0) {
1802 return;
1803 }
1804 const nextRuntimeMode =
1805 runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null;
1806 set((state) => {
1807 const existing = state.draftsByThreadId[threadId];
1808 if (!existing && nextRuntimeMode === null) {
1809 return state;
1810 }
1811 const base = existing ?? createEmptyThreadDraft();
1812 if (base.runtimeMode === nextRuntimeMode) {
1813 return state;
1814 }
1815 const nextDraft: ComposerThreadDraftState = {
1816 ...base,
1817 runtimeMode: nextRuntimeMode,
1818 };
1819 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1820 if (shouldRemoveDraft(nextDraft)) {
1821 delete nextDraftsByThreadId[threadId];
1822 } else {
1823 nextDraftsByThreadId[threadId] = nextDraft;
1824 }
1825 return { draftsByThreadId: nextDraftsByThreadId };
1826 });
1827 },
1828 setInteractionMode: (threadId, interactionMode) => {
1829 if (threadId.length === 0) {
1830 return;
1831 }
1832 const nextInteractionMode =
1833 interactionMode === "plan" || interactionMode === "default" ? interactionMode : null;
1834 set((state) => {
1835 const existing = state.draftsByThreadId[threadId];
1836 if (!existing && nextInteractionMode === null) {
1837 return state;
2317 const nextDraft: ComposerThreadDraftState = {
2318 ...base,
2319 modelSelectionByProvider: nextMap,
2320 };
2321 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2322 if (shouldRemoveDraft(nextDraft)) {
2323 delete nextDraftsByThreadKey[threadKey];
2324 } else {
2325 nextDraftsByThreadKey[threadKey] = nextDraft;
2326 }
2327
2328 return {
2329 draftsByThreadKey: nextDraftsByThreadKey,
2330 ...(options?.persistSticky === true
2331 ? {
2332 stickyModelSelectionByProvider: nextStickyMap,
2333 stickyActiveProvider: nextStickyActiveProvider,
2334 }
2335 : {}),
2336 };
2337 });
2338 },
2339 setRuntimeMode: (threadRef, runtimeMode) => {
2340 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2341 if (threadKey.length === 0) {
2342 return;
18382343 }
1839 const base = existing ?? createEmptyThreadDraft();
1840 if (base.interactionMode === nextInteractionMode) {
1841 return state;
2344 const nextRuntimeMode =
2345 runtimeMode === "approval-required" || runtimeMode === "full-access"
2346 ? runtimeMode
2347 : null;
2348 set((state) => {
2349 const existing = state.draftsByThreadKey[threadKey];
2350 if (!existing && nextRuntimeMode === null) {
2351 return state;
2352 }
2353 const base = existing ?? createEmptyThreadDraft();
2354 if (base.runtimeMode === nextRuntimeMode) {
2355 return state;
2356 }
2357 const nextDraft: ComposerThreadDraftState = {
2358 ...base,
2359 runtimeMode: nextRuntimeMode,
2360 };
2361 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2362 if (shouldRemoveDraft(nextDraft)) {
2363 delete nextDraftsByThreadKey[threadKey];
2364 } else {
2365 nextDraftsByThreadKey[threadKey] = nextDraft;
2366 }
2367 return { draftsByThreadKey: nextDraftsByThreadKey };
2368 });
2369 },
2370 setInteractionMode: (threadRef, interactionMode) => {
2371 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2372 if (threadKey.length === 0) {
2373 return;
18422374 }
1843 const nextDraft: ComposerThreadDraftState = {
1844 ...base,
1845 interactionMode: nextInteractionMode,
1846 };
1847 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1848 if (shouldRemoveDraft(nextDraft)) {
1849 delete nextDraftsByThreadId[threadId];
1850 } else {
1851 nextDraftsByThreadId[threadId] = nextDraft;
2375 const nextInteractionMode =
2376 interactionMode === "plan" || interactionMode === "default" ? interactionMode : null;
2377 set((state) => {
2378 const existing = state.draftsByThreadKey[threadKey];
2379 if (!existing && nextInteractionMode === null) {
2380 return state;
2381 }
2382 const base = existing ?? createEmptyThreadDraft();
2383 if (base.interactionMode === nextInteractionMode) {
2384 return state;
2385 }
2386 const nextDraft: ComposerThreadDraftState = {
2387 ...base,
2388 interactionMode: nextInteractionMode,
2389 };
2390 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2391 if (shouldRemoveDraft(nextDraft)) {
2392 delete nextDraftsByThreadKey[threadKey];
2393 } else {
2394 nextDraftsByThreadKey[threadKey] = nextDraft;
2395 }
2396 return { draftsByThreadKey: nextDraftsByThreadKey };
2397 });
2398 },
2399 addImage: (threadRef, image) => {
2400 const threadKey = resolveComposerDraftKey(get(), threadRef);
2401 const threadId = resolveComposerThreadId(get(), threadRef);
2402 if (!threadKey || !threadId) {
2403 return;
18522404 }
1853 return { draftsByThreadId: nextDraftsByThreadId };
1854 });
1855 },
1856 addImage: (threadId, image) => {
1857 if (threadId.length === 0) {
1858 return;
1859 }
1860 get().addImages(threadId, [image]);
1861 },
1862 addImages: (threadId, images) => {
1863 if (threadId.length === 0 || images.length === 0) {
1864 return;
1865 }
1866 set((state) => {
1867 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1868 const existingIds = new Set(existing.images.map((image) => image.id));
1869 const existingDedupKeys = new Set(
1870 existing.images.map((image) => composerImageDedupKey(image)),
2405 get().addImages(
2406 typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef,
2407 [image],
18712408 );
1872 const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl));
1873 const dedupedIncoming: ComposerImageAttachment[] = [];
1874 for (const image of images) {
1875 const dedupKey = composerImageDedupKey(image);
1876 if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) {
1877 // Avoid revoking a blob URL that's still referenced by an accepted image.
1878 if (!acceptedPreviewUrls.has(image.previewUrl)) {
1879 revokeObjectPreviewUrl(image.previewUrl);
2409 },
2410 addImages: (threadRef, images) => {
2411 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2412 if (threadKey.length === 0 || images.length === 0) {
2413 return;
2414 }
2415 set((state) => {
2416 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2417 const existingIds = new Set(existing.images.map((image) => image.id));
2418 const existingDedupKeys = new Set(
2419 existing.images.map((image) => composerImageDedupKey(image)),
2420 );
2421 const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl));
2422 const dedupedIncoming: ComposerImageAttachment[] = [];
2423 for (const image of images) {
2424 const dedupKey = composerImageDedupKey(image);
2425 if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) {
2426 // Avoid revoking a blob URL that's still referenced by an accepted image.
2427 if (!acceptedPreviewUrls.has(image.previewUrl)) {
2428 revokeObjectPreviewUrl(image.previewUrl);
2429 }
2430 continue;
18802431 }
1881 continue;
2432 dedupedIncoming.push(image);
2433 existingIds.add(image.id);
2434 existingDedupKeys.add(dedupKey);
2435 acceptedPreviewUrls.add(image.previewUrl);
18822436 }
1883 dedupedIncoming.push(image);
1884 existingIds.add(image.id);
1885 existingDedupKeys.add(dedupKey);
1886 acceptedPreviewUrls.add(image.previewUrl);
1887 }
1888 if (dedupedIncoming.length === 0) {
1889 return state;
1890 }
1891 return {
1892 draftsByThreadId: {
1893 ...state.draftsByThreadId,
1894 [threadId]: {
1895 ...existing,
1896 images: [...existing.images, ...dedupedIncoming],
2437 if (dedupedIncoming.length === 0) {
2438 return state;
2439 }
2440 return {
2441 draftsByThreadKey: {
2442 ...state.draftsByThreadKey,
2443 [threadKey]: {
2444 ...existing,
2445 images: [...existing.images, ...dedupedIncoming],
2446 },
18972447 },
1898 },
1899 };
1900 });
1901 },
1902 removeImage: (threadId, imageId) => {
1903 if (threadId.length === 0) {
1904 return;
1905 }
1906 const existing = get().draftsByThreadId[threadId];
1907 if (!existing) {
1908 return;
1909 }
1910 const removedImage = existing.images.find((image) => image.id === imageId);
1911 if (removedImage) {
1912 revokeObjectPreviewUrl(removedImage.previewUrl);
1913 }
1914 set((state) => {
1915 const current = state.draftsByThreadId[threadId];
1916 if (!current) {
1917 return state;
1918 }
1919 const nextDraft: ComposerThreadDraftState = {
1920 ...current,
1921 images: current.images.filter((image) => image.id !== imageId),
1922 nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId),
1923 persistedAttachments: current.persistedAttachments.filter(
1924 (attachment) => attachment.id !== imageId,
1925 ),
1926 };
1927 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1928 if (shouldRemoveDraft(nextDraft)) {
1929 delete nextDraftsByThreadId[threadId];
1930 } else {
1931 nextDraftsByThreadId[threadId] = nextDraft;
2448 };
2449 });
2450 },
2451 removeImage: (threadRef, imageId) => {
2452 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2453 if (threadKey.length === 0) {
2454 return;
19322455 }
1933 return { draftsByThreadId: nextDraftsByThreadId };
1934 });
1935 },
1936 insertTerminalContext: (threadId, prompt, context, index) => {
1937 if (threadId.length === 0) {
1938 return false;
1939 }
1940 let inserted = false;
1941 set((state) => {
1942 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1943 const normalizedContext = normalizeTerminalContextForThread(threadId, context);
1944 if (!normalizedContext) {
1945 return state;
2456 const existing = get().draftsByThreadKey[threadKey];
2457 if (!existing) {
2458 return;
19462459 }
1947 const dedupKey = terminalContextDedupKey(normalizedContext);
1948 if (
1949 existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) ||
1950 existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey)
1951 ) {
1952 return state;
2460 const removedImage = existing.images.find((image) => image.id === imageId);
2461 if (removedImage) {
2462 revokeObjectPreviewUrl(removedImage.previewUrl);
19532463 }
1954 inserted = true;
1955 const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index));
1956 const nextDraft: ComposerThreadDraftState = {
1957 ...existing,
1958 prompt,
1959 terminalContexts: [
1960 ...existing.terminalContexts.slice(0, boundedIndex),
1961 normalizedContext,
1962 ...existing.terminalContexts.slice(boundedIndex),
1963 ],
1964 };
1965 return {
1966 draftsByThreadId: {
1967 ...state.draftsByThreadId,
1968 [threadId]: nextDraft,
1969 },
1970 };
1971 });
1972 return inserted;
1973 },
1974 addTerminalContext: (threadId, context) => {
1975 if (threadId.length === 0) {
1976 return;
1977 }
1978 get().addTerminalContexts(threadId, [context]);
1979 },
1980 addTerminalContexts: (threadId, contexts) => {
1981 if (threadId.length === 0 || contexts.length === 0) {
1982 return;
1983 }
1984 set((state) => {
1985 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1986 const acceptedContexts = normalizeTerminalContextsForThread(threadId, [
1987 ...existing.terminalContexts,
1988 ...contexts,
1989 ]).slice(existing.terminalContexts.length);
1990 if (acceptedContexts.length === 0) {
1991 return state;
2464 set((state) => {
2465 const current = state.draftsByThreadKey[threadKey];
2466 if (!current) {
2467 return state;
2468 }
2469 const nextDraft: ComposerThreadDraftState = {
2470 ...current,
2471 images: current.images.filter((image) => image.id !== imageId),
2472 nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId),
2473 persistedAttachments: current.persistedAttachments.filter(
2474 (attachment) => attachment.id !== imageId,
2475 ),
2476 };
2477 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2478 if (shouldRemoveDraft(nextDraft)) {
2479 delete nextDraftsByThreadKey[threadKey];
2480 } else {
2481 nextDraftsByThreadKey[threadKey] = nextDraft;
2482 }
2483 return { draftsByThreadKey: nextDraftsByThreadKey };
2484 });
2485 },
2486 insertTerminalContext: (threadRef, prompt, context, index) => {
2487 const threadKey = resolveComposerDraftKey(get(), threadRef);
2488 const threadId = resolveComposerThreadId(get(), threadRef);
2489 if (!threadKey || !threadId) {
2490 return false;
19922491 }
1993 return {
1994 draftsByThreadId: {
1995 ...state.draftsByThreadId,
1996 [threadId]: {
1997 ...existing,
1998 prompt: ensureInlineTerminalContextPlaceholders(
1999 existing.prompt,
2000 existing.terminalContexts.length + acceptedContexts.length,
2001 ),
2002 terminalContexts: [...existing.terminalContexts, ...acceptedContexts],
2492 let inserted = false;
2493 set((state) => {
2494 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2495 const normalizedContext = normalizeTerminalContextForThread(threadId, context);
2496 if (!normalizedContext) {
2497 return state;
2498 }
2499 const dedupKey = terminalContextDedupKey(normalizedContext);
2500 if (
2501 existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) ||
2502 existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey)
2503 ) {
2504 return state;
2505 }
2506 inserted = true;
2507 const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index));
2508 const nextDraft: ComposerThreadDraftState = {
2509 ...existing,
2510 prompt,
2511 terminalContexts: [
2512 ...existing.terminalContexts.slice(0, boundedIndex),
2513 normalizedContext,
2514 ...existing.terminalContexts.slice(boundedIndex),
2515 ],
2516 };
2517 return {
2518 draftsByThreadKey: {
2519 ...state.draftsByThreadKey,
2520 [threadKey]: nextDraft,
20032521 },
2004 },
2005 };
2006 });
2007 },
2008 removeTerminalContext: (threadId, contextId) => {
2009 if (threadId.length === 0 || contextId.length === 0) {
2010 return;
2011 }
2012 set((state) => {
2013 const current = state.draftsByThreadId[threadId];
2014 if (!current) {
2015 return state;
2016 }
2017 const nextDraft: ComposerThreadDraftState = {
2018 ...current,
2019 terminalContexts: current.terminalContexts.filter(
2020 (context) => context.id !== contextId,
2021 ),
2022 };
2023 const nextDraftsByThreadId = { ...state.draftsByThreadId };
2024 if (shouldRemoveDraft(nextDraft)) {
2025 delete nextDraftsByThreadId[threadId];
2026 } else {
2027 nextDraftsByThreadId[threadId] = nextDraft;
2028 }
2029 return { draftsByThreadId: nextDraftsByThreadId };
2030 });
2031 },
2032 clearTerminalContexts: (threadId) => {
2033 if (threadId.length === 0) {
2034 return;
2035 }
2036 set((state) => {
2037 const current = state.draftsByThreadId[threadId];
2038 if (!current || current.terminalContexts.length === 0) {
2039 return state;
2040 }
2041 const nextDraft: ComposerThreadDraftState = {
2042 ...current,
2043 terminalContexts: [],
2044 };
2045 const nextDraftsByThreadId = { ...state.draftsByThreadId };
2046 if (shouldRemoveDraft(nextDraft)) {
2047 delete nextDraftsByThreadId[threadId];
2048 } else {
2049 nextDraftsByThreadId[threadId] = nextDraft;
2522 };
2523 });
2524 return inserted;
2525 },
2526 addTerminalContext: (threadRef, context) => {
2527 const threadKey = resolveComposerDraftKey(get(), threadRef);
2528 const threadId = resolveComposerThreadId(get(), threadRef);
2529 if (!threadKey || !threadId) {
2530 return;
20502531 }
2051 return { draftsByThreadId: nextDraftsByThreadId };
2052 });
2053 },
2054 clearPersistedAttachments: (threadId) => {
2055 if (threadId.length === 0) {
2056 return;
2057 }
2058 set((state) => {
2059 const current = state.draftsByThreadId[threadId];
2060 if (!current) {
2061 return state;
2532 get().addTerminalContexts(
2533 typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef,
2534 [context],
2535 );
2536 },
2537 addTerminalContexts: (threadRef, contexts) => {
2538 const threadKey = resolveComposerDraftKey(get(), threadRef);
2539 const threadId = resolveComposerThreadId(get(), threadRef);
2540 if (!threadKey || !threadId || contexts.length === 0) {
2541 return;
20622542 }
2063 const nextDraft: ComposerThreadDraftState = {
2064 ...current,
2065 persistedAttachments: [],
2066 nonPersistedImageIds: [],
2067 };
2068 const nextDraftsByThreadId = { ...state.draftsByThreadId };
2069 if (shouldRemoveDraft(nextDraft)) {
2070 delete nextDraftsByThreadId[threadId];
2071 } else {
2072 nextDraftsByThreadId[threadId] = nextDraft;
2543 set((state) => {
2544 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
2545 const acceptedContexts = normalizeTerminalContextsForThread(threadId, [
2546 ...existing.terminalContexts,
2547 ...contexts,
2548 ]).slice(existing.terminalContexts.length);
2549 if (acceptedContexts.length === 0) {
2550 return state;
2551 }
2552 return {
2553 draftsByThreadKey: {
2554 ...state.draftsByThreadKey,
2555 [threadKey]: {
2556 ...existing,
2557 prompt: ensureInlineTerminalContextPlaceholders(
2558 existing.prompt,
2559 existing.terminalContexts.length + acceptedContexts.length,
2560 ),
2561 terminalContexts: [...existing.terminalContexts, ...acceptedContexts],
2562 },
2563 },
2564 };
2565 });
2566 },
2567 removeTerminalContext: (threadRef, contextId) => {
2568 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2569 if (threadKey.length === 0 || contextId.length === 0) {
2570 return;
20732571 }
2074 return { draftsByThreadId: nextDraftsByThreadId };
2075 });
2076 },
2077 syncPersistedAttachments: (threadId, attachments) => {
2078 if (threadId.length === 0) {
2079 return;
2080 }
2081 const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id));
2082 set((state) => {
2083 const current = state.draftsByThreadId[threadId];
2084 if (!current) {
2085 return state;
2572 set((state) => {
2573 const current = state.draftsByThreadKey[threadKey];
2574 if (!current) {
2575 return state;
2576 }
2577 const nextDraft: ComposerThreadDraftState = {
2578 ...current,
2579 terminalContexts: current.terminalContexts.filter(
2580 (context) => context.id !== contextId,
2581 ),
2582 };
2583 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2584 if (shouldRemoveDraft(nextDraft)) {
2585 delete nextDraftsByThreadKey[threadKey];
2586 } else {
2587 nextDraftsByThreadKey[threadKey] = nextDraft;
2588 }
2589 return { draftsByThreadKey: nextDraftsByThreadKey };
2590 });
2591 },
2592 clearTerminalContexts: (threadRef) => {
2593 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2594 if (threadKey.length === 0) {
2595 return;
20862596 }
2087 const nextDraft: ComposerThreadDraftState = {
2088 ...current,
2089 // Stage attempted attachments so persist middleware can try writing them.
2090 persistedAttachments: attachments,
2091 nonPersistedImageIds: current.nonPersistedImageIds.filter(
2092 (id) => !attachmentIdSet.has(id),
2093 ),
2094 };
2095 const nextDraftsByThreadId = { ...state.draftsByThreadId };
2096 if (shouldRemoveDraft(nextDraft)) {
2097 delete nextDraftsByThreadId[threadId];
2098 } else {
2099 nextDraftsByThreadId[threadId] = nextDraft;
2597 set((state) => {
2598 const current = state.draftsByThreadKey[threadKey];
2599 if (!current || current.terminalContexts.length === 0) {
2600 return state;
2601 }
2602 const nextDraft: ComposerThreadDraftState = {
2603 ...current,
2604 terminalContexts: [],
2605 };
2606 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2607 if (shouldRemoveDraft(nextDraft)) {
2608 delete nextDraftsByThreadKey[threadKey];
2609 } else {
2610 nextDraftsByThreadKey[threadKey] = nextDraft;
2611 }
2612 return { draftsByThreadKey: nextDraftsByThreadKey };
2613 });
2614 },
2615 clearPersistedAttachments: (threadRef) => {
2616 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2617 if (threadKey.length === 0) {
2618 return;
21002619 }
2101 return { draftsByThreadId: nextDraftsByThreadId };
2102 });
2103 Promise.resolve().then(() => {
2104 verifyPersistedAttachments(threadId, attachments, set);
2105 });
2106 },
2107 clearComposerContent: (threadId) => {
2108 if (threadId.length === 0) {
2109 return;
2110 }
2111 set((state) => {
2112 const current = state.draftsByThreadId[threadId];
2113 if (!current) {
2114 return state;
2620 set((state) => {
2621 const current = state.draftsByThreadKey[threadKey];
2622 if (!current) {
2623 return state;
2624 }
2625 const nextDraft: ComposerThreadDraftState = {
2626 ...current,
2627 persistedAttachments: [],
2628 nonPersistedImageIds: [],
2629 };
2630 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2631 if (shouldRemoveDraft(nextDraft)) {
2632 delete nextDraftsByThreadKey[threadKey];
2633 } else {
2634 nextDraftsByThreadKey[threadKey] = nextDraft;
2635 }
2636 return { draftsByThreadKey: nextDraftsByThreadKey };
2637 });
2638 },
2639 syncPersistedAttachments: (threadRef, attachments) => {
2640 const threadKey = resolveComposerDraftKey(get(), threadRef);
2641 if (!threadKey) {
2642 return;
21152643 }
2116 const nextDraft: ComposerThreadDraftState = {
2117 ...current,
2118 prompt: "",
2119 images: [],
2120 nonPersistedImageIds: [],
2121 persistedAttachments: [],
2122 terminalContexts: [],
2123 };
2124 const nextDraftsByThreadId = { ...state.draftsByThreadId };
2125 if (shouldRemoveDraft(nextDraft)) {
2126 delete nextDraftsByThreadId[threadId];
2127 } else {
2128 nextDraftsByThreadId[threadId] = nextDraft;
2644 const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id));
2645 set((state) => {
2646 const current = state.draftsByThreadKey[threadKey];
2647 if (!current) {
2648 return state;
2649 }
2650 const nextDraft: ComposerThreadDraftState = {
2651 ...current,
2652 // Stage attempted attachments so persist middleware can try writing them.
2653 persistedAttachments: attachments,
2654 nonPersistedImageIds: current.nonPersistedImageIds.filter(
2655 (id) => !attachmentIdSet.has(id),
2656 ),
2657 };
2658 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2659 if (shouldRemoveDraft(nextDraft)) {
2660 delete nextDraftsByThreadKey[threadKey];
2661 } else {
2662 nextDraftsByThreadKey[threadKey] = nextDraft;
2663 }
2664 return { draftsByThreadKey: nextDraftsByThreadKey };
2665 });
2666 Promise.resolve().then(() => {
2667 verifyPersistedAttachments(threadKey, attachments, set);
2668 });
2669 },
2670 clearComposerContent: (threadRef) => {
2671 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2672 if (threadKey.length === 0) {
2673 return;
21292674 }
2130 return { draftsByThreadId: nextDraftsByThreadId };
2131 });
2132 },
2133 }),
2675 set((state) => {
2676 const current = state.draftsByThreadKey[threadKey];
2677 if (!current) {
2678 return state;
2679 }
2680 const nextDraft: ComposerThreadDraftState = {
2681 ...current,
2682 prompt: "",
2683 images: [],
2684 nonPersistedImageIds: [],
2685 persistedAttachments: [],
2686 terminalContexts: [],
2687 };
2688 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2689 if (shouldRemoveDraft(nextDraft)) {
2690 delete nextDraftsByThreadKey[threadKey];
2691 } else {
2692 nextDraftsByThreadKey[threadKey] = nextDraft;
2693 }
2694 return { draftsByThreadKey: nextDraftsByThreadKey };
2695 });
2696 },
2697 };
2698 },
21342699 {
21352700 name: COMPOSER_DRAFT_STORAGE_KEY,
21362701 version: COMPOSER_DRAFT_STORAGE_VERSION,
......@@ -2140,17 +2705,23 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
21402705 merge: (persistedState, currentState) => {
21412706 const normalizedPersisted =
21422707 normalizeCurrentPersistedComposerDraftStoreState(persistedState);
2143 const draftsByThreadId = Object.fromEntries(
2144 Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [
2145 threadId,
2708 const draftsByThreadKey = Object.fromEntries(
2709 Object.entries(normalizedPersisted.draftsByThreadKey).map(([threadKey, draft]) => [
2710 threadKey,
21462711 toHydratedThreadDraft(draft),
21472712 ]),
21482713 );
2714 const draftThreadsByThreadKey = Object.fromEntries(
2715 Object.entries(normalizedPersisted.draftThreadsByThreadKey).map(
2716 ([threadKey, draftThread]) => [threadKey, toHydratedDraftThreadState(draftThread)],
2717 ),
2718 ) as Record<string, DraftThreadState>;
21492719 return {
21502720 ...currentState,
2151 draftsByThreadId,
2152 draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId,
2153 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,
2721 draftsByThreadKey,
2722 draftThreadsByThreadKey,
2723 logicalProjectDraftThreadKeyByLogicalProjectKey:
2724 normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey,
21542725 stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {},
21552726 stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null,
21562727 };
......@@ -2159,19 +2730,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
21592730 ),
21602731);
21612732
2162export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState {
2163 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);
2733export const useComposerDraftStore = composerDraftStore;
2734
2735export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState {
2736 return useComposerDraftStore((state) => {
2737 return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT;
2738 });
21642739}
21652740
21662741export function useEffectiveComposerModelState(input: {
2167 threadId: ThreadId;
2742 threadRef?: ComposerThreadTarget;
2743 draftId?: DraftId;
21682744 providers: ReadonlyArray<ServerProvider>;
21692745 selectedProvider: ProviderKind;
21702746 threadModelSelection: ModelSelection | null | undefined;
21712747 projectModelSelection: ModelSelection | null | undefined;
21722748 settings: UnifiedSettings;
21732749}): EffectiveComposerModelState {
2174 const draft = useComposerThreadDraft(input.threadId);
2750 const draft = useComposerThreadDraft(input.threadRef ?? input.draftId ?? DraftId.makeUnsafe(""));
21752751
21762752 return useMemo(
21772753 () =>
......@@ -2195,21 +2771,69 @@ export function useEffectiveComposerModelState(input: {
21952771}
21962772
21972773/**
2198 * Clear a draft thread once the server has materialized the same thread id.
2774 * Mark a draft thread as promoting once the server has materialized the same thread id.
21992775 *
22002776 * Use the single-thread helper for live `thread.created` events and the
22012777 * iterable helper for bootstrap/recovery paths that discover multiple server
22022778 * threads at once.
22032779 */
2204export function clearPromotedDraftThread(threadId: ThreadId): void {
2205 if (!useComposerDraftStore.getState().getDraftThread(threadId)) {
2780export function markPromotedDraftThread(threadId: ThreadId): void {
2781 const store = useComposerDraftStore.getState();
2782 const draftThreadTargets: ComposerThreadTarget[] = [];
2783 for (const [draftId, draftThread] of Object.entries(store.draftThreadsByThreadKey)) {
2784 if (draftThread.threadId === threadId) {
2785 draftThreadTargets.push(DraftId.makeUnsafe(draftId));
2786 }
2787 }
2788 if (draftThreadTargets.length === 0) {
22062789 return;
22072790 }
2208 useComposerDraftStore.getState().clearDraftThread(threadId);
2791 for (const draftThreadTarget of draftThreadTargets) {
2792 store.markDraftThreadPromoting(draftThreadTarget);
2793 }
2794}
2795
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 }
22092806}
22102807
2211export function clearPromotedDraftThreads(serverThreadIds: Iterable<ThreadId>): void {
2808export function markPromotedDraftThreads(serverThreadIds: Iterable<ThreadId>): void {
22122809 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);
22142838 }
22152839}
apps/web/src/editorPreferences.ts+2-2
......@@ -1,4 +1,4 @@
1import { EDITORS, EditorId, NativeApi } from "@t3tools/contracts";
1import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts";
22import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage";
33import { useMemo } from "react";
44
......@@ -26,7 +26,7 @@ export function resolveAndPersistPreferredEditor(
2626 return editor ?? null;
2727}
2828
29export async function openInPreferredEditor(api: NativeApi, targetPath: string): Promise<EditorId> {
29export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise<EditorId> {
3030 const { availableEditors } = await api.server.getConfig();
3131 const editor = resolveAndPersistPreferredEditor(availableEditors);
3232 if (!editor) throw new Error("No available editors found.");
apps/web/src/environmentApi.ts 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 @@
1import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
2import { useNavigate, useParams } from "@tanstack/react-router";
1import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";
2import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts";
3import { useParams, useRouter } from "@tanstack/react-router";
34import { useCallback, useMemo } from "react";
45import { useShallow } from "zustand/react/shallow";
56import {
......@@ -7,37 +8,49 @@ import {
78 type DraftThreadState,
89 useComposerDraftStore,
910} from "../composerDraftStore";
10import { newThreadId } from "../lib/utils";
11import { newDraftId, newThreadId } from "../lib/utils";
1112import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
12import { useStore } from "../store";
13import { createThreadSelector } from "../storeSelectors";
13import { deriveLogicalProjectKey } from "../logicalProject";
14import { selectProjectsAcrossEnvironments, useStore } from "../store";
15import { createThreadSelectorByRef } from "../storeSelectors";
16import { resolveThreadRouteTarget } from "../threadRoutes";
1417import { useUiStateStore } from "../uiStateStore";
1518
1619export function useHandleNewThread() {
17 const projectIds = useStore(useShallow((store) => store.projectIds));
20 const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store)));
1821 const projectOrder = useUiStateStore((store) => store.projectOrder);
19 const navigate = useNavigate();
20 const routeThreadId = useParams({
22 const router = useRouter();
23 const routeTarget = useParams({
2124 strict: false,
22 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
25 select: (params) => resolveThreadRouteTarget(params),
2326 });
27 const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
2428 const activeThread = useStore(
25 useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]),
29 useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]),
2630 );
27 const activeDraftThread = useComposerDraftStore((store) =>
28 routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null,
31 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
32 const activeDraftThread = useComposerDraftStore(() =>
33 routeTarget
34 ? routeTarget.kind === "server"
35 ? getDraftThread(routeTarget.threadRef)
36 : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId)
37 : null,
2938 );
3039 const orderedProjects = useMemo(() => {
3140 return orderItemsByPreferredIds({
32 items: projectIds,
41 items: projects,
3342 preferredIds: projectOrder,
34 getId: (projectId) => projectId,
43 getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
3544 });
36 }, [projectIds, projectOrder]);
45 }, [projectOrder, projects]);
46 const getCurrentRouteTarget = useCallback(() => {
47 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
48 return resolveThreadRouteTarget(currentRouteParams);
49 }, [router]);
3750
3851 const handleNewThread = useCallback(
3952 (
40 projectId: ProjectId,
53 projectRef: ScopedProjectRef,
4154 options?: {
4255 branch?: string | null;
4356 worktreePath?: string | null;
......@@ -45,84 +58,110 @@ export function useHandleNewThread() {
4558 },
4659 ): Promise<void> => {
4760 const {
48 clearProjectDraftThreadId,
61 getDraftSessionByLogicalProjectKey,
62 getDraftSession,
4963 getDraftThread,
50 getDraftThreadByProjectId,
5164 applyStickyState,
5265 setDraftThreadContext,
53 setProjectDraftThreadId,
66 setLogicalProjectDraftThreadId,
5467 } = useComposerDraftStore.getState();
68 const currentRouteTarget = getCurrentRouteTarget();
69 const project = projects.find(
70 (candidate) =>
71 candidate.id === projectRef.projectId &&
72 candidate.environmentId === projectRef.environmentId,
73 );
74 const logicalProjectKey = project
75 ? deriveLogicalProjectKey(project)
76 : scopedProjectKey(projectRef);
5577 const hasBranchOption = options?.branch !== undefined;
5678 const hasWorktreePathOption = options?.worktreePath !== undefined;
5779 const hasEnvModeOption = options?.envMode !== undefined;
58 const storedDraftThread = getDraftThreadByProjectId(projectId);
59 const latestActiveDraftThread: DraftThreadState | null = routeThreadId
60 ? getDraftThread(routeThreadId)
80 const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey);
81 const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget
82 ? currentRouteTarget.kind === "server"
83 ? getDraftThread(currentRouteTarget.threadRef)
84 : getDraftSession(currentRouteTarget.draftId)
6185 : null;
6286 if (storedDraftThread) {
6387 return (async () => {
6488 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
65 setDraftThreadContext(storedDraftThread.threadId, {
89 setDraftThreadContext(storedDraftThread.draftId, {
6690 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
6791 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
6892 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
6993 });
7094 }
71 setProjectDraftThreadId(projectId, storedDraftThread.threadId);
72 if (routeThreadId === storedDraftThread.threadId) {
95 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, storedDraftThread.draftId, {
96 threadId: storedDraftThread.threadId,
97 });
98 if (
99 currentRouteTarget?.kind === "draft" &&
100 currentRouteTarget.draftId === storedDraftThread.draftId
101 ) {
73102 return;
74103 }
75 await navigate({
76 to: "/$threadId",
77 params: { threadId: storedDraftThread.threadId },
104 await router.navigate({
105 to: "/draft/$draftId",
106 params: { draftId: storedDraftThread.draftId },
78107 });
79108 })();
80109 }
81110
82 clearProjectDraftThreadId(projectId);
83
84111 if (
85112 latestActiveDraftThread &&
86 routeThreadId &&
87 latestActiveDraftThread.projectId === projectId
113 currentRouteTarget?.kind === "draft" &&
114 latestActiveDraftThread.logicalProjectKey === logicalProjectKey
88115 ) {
89116 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
90 setDraftThreadContext(routeThreadId, {
117 setDraftThreadContext(currentRouteTarget.draftId, {
91118 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
92119 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
93120 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
94121 });
95122 }
96 setProjectDraftThreadId(projectId, routeThreadId);
123 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, {
124 threadId: latestActiveDraftThread.threadId,
125 createdAt: latestActiveDraftThread.createdAt,
126 runtimeMode: latestActiveDraftThread.runtimeMode,
127 interactionMode: latestActiveDraftThread.interactionMode,
128 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
129 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
130 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
131 });
97132 return Promise.resolve();
98133 }
99134
135 const draftId = newDraftId();
100136 const threadId = newThreadId();
101137 const createdAt = new Date().toISOString();
102138 return (async () => {
103 setProjectDraftThreadId(projectId, threadId, {
139 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
140 threadId,
104141 createdAt,
105142 branch: options?.branch ?? null,
106143 worktreePath: options?.worktreePath ?? null,
107144 envMode: options?.envMode ?? "local",
108145 runtimeMode: DEFAULT_RUNTIME_MODE,
109146 });
110 applyStickyState(threadId);
147 applyStickyState(draftId);
111148
112 await navigate({
113 to: "/$threadId",
114 params: { threadId },
149 await router.navigate({
150 to: "/draft/$draftId",
151 params: { draftId },
115152 });
116153 })();
117154 },
118 [navigate, routeThreadId],
155 [getCurrentRouteTarget, router, projects],
119156 );
120157
121158 return {
122159 activeDraftThread,
123160 activeThread,
124 defaultProjectId: orderedProjects[0] ?? null,
161 defaultProjectRef: orderedProjects[0]
162 ? scopeProjectRef(orderedProjects[0].environmentId, orderedProjects[0].id)
163 : null,
125164 handleNewThread,
126 routeThreadId,
165 routeThreadRef,
127166 };
128167}
apps/web/src/hooks/useSettings.ts+4-6
......@@ -26,7 +26,7 @@ import {
2626 TimestampFormat,
2727 UnifiedSettings,
2828} from "@t3tools/contracts/settings";
29import { ensureNativeApi } from "~/nativeApi";
29import { ensureLocalApi } from "~/localApi";
3030import { useLocalStorage } from "./useLocalStorage";
3131import { normalizeCustomModelSlugs } from "~/modelSelection";
3232import { Predicate, Schema, Struct } from "effect";
......@@ -67,9 +67,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): {
6767 * only re-render when the slice they care about changes.
6868 */
6969
70export function useSettings<T extends UnifiedSettings = UnifiedSettings>(
71 selector?: (s: UnifiedSettings) => T,
72): T {
70export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
7371 const serverSettings = useServerSettings();
7472 const [clientSettings] = useLocalStorage(
7573 CLIENT_SETTINGS_STORAGE_KEY,
......@@ -111,7 +109,7 @@ export function useUpdateSettings() {
111109 applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch));
112110 }
113111 // Fire-and-forget RPC — push will reconcile on success
114 void ensureNativeApi().server.updateSettings(serverPatch);
112 void ensureLocalApi().server.updateSettings(serverPatch);
115113 }
116114
117115 if (Object.keys(clientPatch).length > 0) {
......@@ -239,7 +237,7 @@ export function migrateLocalSettingsToServer(): void {
239237 // Migrate server-relevant keys via RPC
240238 const serverPatch = buildLegacyServerSettingsMigrationPatch(old);
241239 if (Object.keys(serverPatch).length > 0) {
242 const api = ensureNativeApi();
240 const api = ensureLocalApi();
243241 void api.server.updateSettings(serverPatch);
244242 }
245243
apps/web/src/hooks/useThreadActions.ts+128-64
......@@ -1,42 +1,63 @@
1import { ThreadId } from "@t3tools/contracts";
2import { useMutation, useQueryClient } from "@tanstack/react-query";
3import { useNavigate, useParams } from "@tanstack/react-router";
1import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
3import { useQueryClient } from "@tanstack/react-query";
4import { useRouter } from "@tanstack/react-router";
45import { useCallback } from "react";
56
67import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
78import { useComposerDraftStore } from "../composerDraftStore";
89import { useHandleNewThread } from "./useHandleNewThread";
9import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
10import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";
11import { invalidateGitQueries } from "../lib/gitReactQuery";
1012import { newCommandId } from "../lib/utils";
11import { readNativeApi } from "../nativeApi";
12import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store";
13import { readLocalApi } from "../localApi";
14import {
15 selectProjectByRef,
16 selectThreadByRef,
17 selectThreadsForEnvironment,
18 useStore,
19} from "../store";
1320import { useTerminalStateStore } from "../terminalStateStore";
21import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
1422import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1523import { toastManager } from "../components/ui/toast";
1624import { useSettings } from "./useSettings";
1725
1826export function useThreadActions() {
19 const appSettings = useSettings();
27 const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder);
28 const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete);
2029 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
2130 const clearProjectDraftThreadById = useComposerDraftStore(
2231 (store) => store.clearProjectDraftThreadById,
2332 );
2433 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
25 const routeThreadId = useParams({
26 strict: false,
27 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
28 });
29 const navigate = useNavigate();
34 const router = useRouter();
3035 const { handleNewThread } = useHandleNewThread();
3136 const queryClient = useQueryClient();
32 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
37
38 const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {
39 const state = useStore.getState();
40 const thread = selectThreadByRef(state, target);
41 if (!thread) {
42 return null;
43 }
44 return {
45 thread,
46 threadRef: target,
47 };
48 }, []);
49 const getCurrentRouteThreadRef = useCallback(() => {
50 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
51 return resolveThreadRouteRef(currentRouteParams);
52 }, [router]);
3353
3454 const archiveThread = useCallback(
35 async (threadId: ThreadId) => {
36 const api = readNativeApi();
55 async (target: ScopedThreadRef) => {
56 const api = readEnvironmentApi(target.environmentId);
3757 if (!api) return;
38 const thread = selectThreadById(threadId)(useStore.getState());
39 if (!thread) return;
58 const resolved = resolveThreadTarget(target);
59 if (!resolved) return;
60 const { thread, threadRef } = resolved;
4061 if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
4162 throw new Error("Cannot archive a running thread.");
4263 }
......@@ -44,48 +65,69 @@ export function useThreadActions() {
4465 await api.orchestration.dispatchCommand({
4566 type: "thread.archive",
4667 commandId: newCommandId(),
47 threadId,
68 threadId: threadRef.threadId,
4869 });
70 const currentRouteThreadRef = getCurrentRouteThreadRef();
4971
50 if (routeThreadId === threadId) {
51 await handleNewThread(thread.projectId);
72 if (
73 currentRouteThreadRef?.threadId === threadRef.threadId &&
74 currentRouteThreadRef.environmentId === threadRef.environmentId
75 ) {
76 await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId));
5277 }
5378 },
54 [handleNewThread, routeThreadId],
79 [getCurrentRouteThreadRef, handleNewThread, resolveThreadTarget],
5580 );
5681
57 const unarchiveThread = useCallback(async (threadId: ThreadId) => {
58 const api = readNativeApi();
82 const unarchiveThread = useCallback(async (target: ScopedThreadRef) => {
83 const api = readEnvironmentApi(target.environmentId);
5984 if (!api) return;
6085 await api.orchestration.dispatchCommand({
6186 type: "thread.unarchive",
6287 commandId: newCommandId(),
63 threadId,
88 threadId: target.threadId,
6489 });
6590 }, []);
6691
6792 const deleteThread = useCallback(
68 async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => {
69 const api = readNativeApi();
93 async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => {
94 const api = readEnvironmentApi(target.environmentId);
7095 if (!api) return;
96 const resolved = resolveThreadTarget(target);
97 if (!resolved) return;
98 const { thread, threadRef } = resolved;
7199 const state = useStore.getState();
72 const threads = selectThreads(state);
73 const thread = selectThreadById(threadId)(state);
74 if (!thread) return;
75 const threadProject = selectProjectById(thread.projectId)(state);
76 const deletedIds = opts.deletedThreadIds;
100 const threads = selectThreadsForEnvironment(state, threadRef.environmentId);
101 const threadProject = selectProjectByRef(state, {
102 environmentId: threadRef.environmentId,
103 projectId: thread.projectId,
104 });
105 const deletedIds =
106 opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0
107 ? new Set<ThreadId>(
108 [...opts.deletedThreadKeys].flatMap((threadKey) => {
109 const ref = parseScopedThreadKey(threadKey);
110 return ref && ref.environmentId === threadRef.environmentId ? [ref.threadId] : [];
111 }),
112 )
113 : undefined;
77114 const survivingThreads =
78115 deletedIds && deletedIds.size > 0
79 ? threads.filter((entry) => entry.id === threadId || !deletedIds.has(entry.id))
116 ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id))
80117 : threads;
81 const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId);
118 const orphanedWorktreePath = getOrphanedWorktreePathForThread(
119 survivingThreads,
120 threadRef.threadId,
121 );
82122 const displayWorktreePath = orphanedWorktreePath
83123 ? formatWorktreePathForDisplay(orphanedWorktreePath)
84124 : null;
85125 const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined;
126 const localApi = readLocalApi();
86127 const shouldDeleteWorktree =
87128 canDeleteWorktree &&
88 (await api.dialogs.confirm(
129 localApi &&
130 (await localApi.dialogs.confirm(
89131 [
90132 "This thread is the only one linked to this worktree:",
91133 displayWorktreePath ?? orphanedWorktreePath,
......@@ -99,44 +141,60 @@ export function useThreadActions() {
99141 .dispatchCommand({
100142 type: "thread.session.stop",
101143 commandId: newCommandId(),
102 threadId,
144 threadId: threadRef.threadId,
103145 createdAt: new Date().toISOString(),
104146 })
105147 .catch(() => undefined);
106148 }
107149
108150 try {
109 await api.terminal.close({ threadId, deleteHistory: true });
151 await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true });
110152 } catch {
111153 // Terminal may already be closed.
112154 }
113155
114 const deletedThreadIds = opts.deletedThreadIds ?? new Set<ThreadId>();
115 const shouldNavigateToFallback = routeThreadId === threadId;
156 const deletedThreadIds = deletedIds ?? new Set<ThreadId>();
157 const currentRouteThreadRef = getCurrentRouteThreadRef();
158 const shouldNavigateToFallback =
159 currentRouteThreadRef?.threadId === threadRef.threadId &&
160 currentRouteThreadRef.environmentId === threadRef.environmentId;
116161 const fallbackThreadId = getFallbackThreadIdAfterDelete({
117162 threads,
118 deletedThreadId: threadId,
163 deletedThreadId: threadRef.threadId,
119164 deletedThreadIds,
120 sortOrder: appSettings.sidebarThreadSortOrder,
165 sortOrder: sidebarThreadSortOrder,
121166 });
122167 await api.orchestration.dispatchCommand({
123168 type: "thread.delete",
124169 commandId: newCommandId(),
125 threadId,
170 threadId: threadRef.threadId,
126171 });
127 clearComposerDraftForThread(threadId);
128 clearProjectDraftThreadById(thread.projectId, thread.id);
129 clearTerminalState(threadId);
172 clearComposerDraftForThread(threadRef);
173 clearProjectDraftThreadById(
174 scopeProjectRef(threadRef.environmentId, thread.projectId),
175 threadRef,
176 );
177 clearTerminalState(threadRef);
130178
131179 if (shouldNavigateToFallback) {
132180 if (fallbackThreadId) {
133 await navigate({
134 to: "/$threadId",
135 params: { threadId: fallbackThreadId },
136 replace: true,
137 });
181 const fallbackThread = selectThreadByRef(
182 useStore.getState(),
183 scopeThreadRef(threadRef.environmentId, fallbackThreadId),
184 );
185 if (fallbackThread) {
186 await router.navigate({
187 to: "/$environmentId/$threadId",
188 params: buildThreadRouteParams(
189 scopeThreadRef(fallbackThread.environmentId, fallbackThread.id),
190 ),
191 replace: true,
192 });
193 } else {
194 await router.navigate({ to: "/", replace: true });
195 }
138196 } else {
139 await navigate({ to: "/", replace: true });
197 await router.navigate({ to: "/", replace: true });
140198 }
141199 }
142200
......@@ -145,15 +203,18 @@ export function useThreadActions() {
145203 }
146204
147205 try {
148 await removeWorktreeMutation.mutateAsync({
206 await ensureEnvironmentApi(threadRef.environmentId).git.removeWorktree({
149207 cwd: threadProject.cwd,
150208 path: orphanedWorktreePath,
151209 force: true,
152210 });
211 await invalidateGitQueries(queryClient, {
212 environmentId: threadRef.environmentId,
213 });
153214 } catch (error) {
154215 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";
155216 console.error("Failed to remove orphaned worktree after thread deletion", {
156 threadId,
217 threadId: threadRef.threadId,
157218 projectCwd: threadProject.cwd,
158219 worktreePath: orphanedWorktreePath,
159220 error,
......@@ -169,22 +230,25 @@ export function useThreadActions() {
169230 clearComposerDraftForThread,
170231 clearProjectDraftThreadById,
171232 clearTerminalState,
172 appSettings.sidebarThreadSortOrder,
173 navigate,
174 removeWorktreeMutation,
175 routeThreadId,
233 getCurrentRouteThreadRef,
234 router,
235 queryClient,
236 resolveThreadTarget,
237 sidebarThreadSortOrder,
176238 ],
177239 );
178240
179241 const confirmAndDeleteThread = useCallback(
180 async (threadId: ThreadId) => {
181 const api = readNativeApi();
242 async (target: ScopedThreadRef) => {
243 const api = readEnvironmentApi(target.environmentId);
182244 if (!api) return;
183 const thread = selectThreadById(threadId)(useStore.getState());
184 if (!thread) return;
245 const localApi = readLocalApi();
246 const resolved = resolveThreadTarget(target);
247 if (!resolved) return;
248 const { thread } = resolved;
185249
186 if (appSettings.confirmThreadDelete) {
187 const confirmed = await api.dialogs.confirm(
250 if (confirmThreadDelete && localApi) {
251 const confirmed = await localApi.dialogs.confirm(
188252 [
189253 `Delete thread "${thread.title}"?`,
190254 "This permanently clears conversation history for this thread.",
......@@ -195,9 +259,9 @@ export function useThreadActions() {
195259 }
196260 }
197261
198 await deleteThread(threadId);
262 await deleteThread(target);
199263 },
200 [appSettings.confirmThreadDelete, deleteThread],
264 [confirmThreadDelete, deleteThread, resolveThreadTarget],
201265 );
202266
203267 return {
apps/web/src/lib/gitReactQuery.test.ts+30-13
......@@ -1,16 +1,17 @@
11import { QueryClient } from "@tanstack/react-query";
22import { describe, expect, it, vi } from "vitest";
33
4vi.mock("../nativeApi", () => ({
5 ensureNativeApi: vi.fn(),
4vi.mock("../environmentApi", () => ({
5 ensureEnvironmentApi: vi.fn(),
66}));
77
88vi.mock("../wsRpcClient", () => ({
99 getWsRpcClient: vi.fn(),
10 getWsRpcClientForEnvironment: vi.fn(),
1011}));
1112
1213import type { InfiniteData } from "@tanstack/react-query";
13import type { GitListBranchesResult } from "@t3tools/contracts";
14import { EnvironmentId, type GitListBranchesResult } from "@t3tools/contracts";
1415
1516import {
1617 gitBranchSearchInfiniteQueryOptions,
......@@ -33,21 +34,25 @@ const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = {
3334 pages: [BRANCH_QUERY_RESULT],
3435 pageParams: [0],
3536};
37const ENVIRONMENT_A = EnvironmentId.makeUnsafe("environment-a");
38const ENVIRONMENT_B = EnvironmentId.makeUnsafe("environment-b");
3639
3740describe("gitMutationKeys", () => {
3841 it("scopes stacked action keys by cwd", () => {
39 expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual(
40 gitMutationKeys.runStackedAction("/repo/b"),
42 expect(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")).not.toEqual(
43 gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/b"),
4144 );
4245 });
4346
4447 it("scopes pull keys by cwd", () => {
45 expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b"));
48 expect(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")).not.toEqual(
49 gitMutationKeys.pull(ENVIRONMENT_A, "/repo/b"),
50 );
4651 });
4752
4853 it("scopes pull request thread preparation keys by cwd", () => {
49 expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual(
50 gitMutationKeys.preparePullRequestThread("/repo/b"),
54 expect(gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a")).not.toEqual(
55 gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/b"),
5156 );
5257 });
5358});
......@@ -57,23 +62,31 @@ describe("git mutation options", () => {
5762
5863 it("attaches cwd-scoped mutation key for runStackedAction", () => {
5964 const options = gitRunStackedActionMutationOptions({
65 environmentId: ENVIRONMENT_A,
6066 cwd: "/repo/a",
6167 queryClient,
6268 });
63 expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a"));
69 expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a"));
6470 });
6571
6672 it("attaches cwd-scoped mutation key for pull", () => {
67 const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });
68 expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));
73 const options = gitPullMutationOptions({
74 environmentId: ENVIRONMENT_A,
75 cwd: "/repo/a",
76 queryClient,
77 });
78 expect(options.mutationKey).toEqual(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a"));
6979 });
7080
7181 it("attaches cwd-scoped mutation key for preparePullRequestThread", () => {
7282 const options = gitPreparePullRequestThreadMutationOptions({
83 environmentId: ENVIRONMENT_A,
7384 cwd: "/repo/a",
7485 queryClient,
7586 });
76 expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a"));
87 expect(options.mutationKey).toEqual(
88 gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a"),
89 );
7790 });
7891});
7992
......@@ -83,6 +96,7 @@ describe("invalidateGitQueries", () => {
8396
8497 queryClient.setQueryData(
8598 gitBranchSearchInfiniteQueryOptions({
99 environmentId: ENVIRONMENT_A,
86100 cwd: "/repo/a",
87101 query: "feature",
88102 }).queryKey,
......@@ -90,17 +104,19 @@ describe("invalidateGitQueries", () => {
90104 );
91105 queryClient.setQueryData(
92106 gitBranchSearchInfiniteQueryOptions({
107 environmentId: ENVIRONMENT_B,
93108 cwd: "/repo/b",
94109 query: "feature",
95110 }).queryKey,
96111 BRANCH_SEARCH_RESULT,
97112 );
98113
99 await invalidateGitQueries(queryClient, { cwd: "/repo/a" });
114 await invalidateGitQueries(queryClient, { environmentId: ENVIRONMENT_A, cwd: "/repo/a" });
100115
101116 expect(
102117 queryClient.getQueryState(
103118 gitBranchSearchInfiniteQueryOptions({
119 environmentId: ENVIRONMENT_A,
104120 cwd: "/repo/a",
105121 query: "feature",
106122 }).queryKey,
......@@ -109,6 +125,7 @@ describe("invalidateGitQueries", () => {
109125 expect(
110126 queryClient.getQueryState(
111127 gitBranchSearchInfiniteQueryOptions({
128 environmentId: ENVIRONMENT_B,
112129 cwd: "/repo/b",
113130 query: "feature",
114131 }).queryKey,
apps/web/src/lib/gitReactQuery.ts+105-53
......@@ -1,4 +1,5 @@
11import {
2 type EnvironmentId,
23 type GitActionProgressEvent,
34 type GitStackedAction,
45 type ThreadId,
......@@ -9,8 +10,8 @@ import {
910 queryOptions,
1011 type QueryClient,
1112} from "@tanstack/react-query";
12import { ensureNativeApi } from "../nativeApi";
13import { getWsRpcClient } from "../wsRpcClient";
13import { ensureEnvironmentApi } from "../environmentApi";
14import { getWsRpcClientForEnvironment } from "../wsRpcClient";
1415
1516const GIT_BRANCHES_STALE_TIME_MS = 15_000;
1617const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
......@@ -18,38 +19,52 @@ const GIT_BRANCHES_PAGE_SIZE = 100;
1819
1920export const gitQueryKeys = {
2021 all: ["git"] as const,
21 branches: (cwd: string | null) => ["git", "branches", cwd] as const,
22 branchSearch: (cwd: string | null, query: string) =>
23 ["git", "branches", cwd, "search", query] as const,
22 branches: (environmentId: EnvironmentId | null, cwd: string | null) =>
23 ["git", "branches", environmentId ?? null, cwd] as const,
24 branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) =>
25 ["git", "branches", environmentId ?? null, cwd, "search", query] as const,
2426};
2527
2628export const gitMutationKeys = {
27 init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const,
28 checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const,
29 runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const,
30 pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,
31 preparePullRequestThread: (cwd: string | null) =>
32 ["git", "mutation", "prepare-pull-request-thread", cwd] as const,
29 init: (environmentId: EnvironmentId | null, cwd: string | null) =>
30 ["git", "mutation", "init", environmentId ?? null, cwd] as const,
31 checkout: (environmentId: EnvironmentId | null, cwd: string | null) =>
32 ["git", "mutation", "checkout", environmentId ?? null, cwd] as const,
33 runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) =>
34 ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const,
35 pull: (environmentId: EnvironmentId | null, cwd: string | null) =>
36 ["git", "mutation", "pull", environmentId ?? null, cwd] as const,
37 preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) =>
38 ["git", "mutation", "prepare-pull-request-thread", environmentId ?? null, cwd] as const,
3339};
3440
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;
3646 const cwd = input?.cwd ?? null;
3747 if (cwd !== null) {
38 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) });
48 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) });
3949 }
4050
4151 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
4252}
4353
44function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null) {
54function invalidateGitBranchQueries(
55 queryClient: QueryClient,
56 environmentId: EnvironmentId | null,
57 cwd: string | null,
58) {
4559 if (cwd === null) {
4660 return Promise.resolve();
4761 }
4862
49 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) });
63 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) });
5064}
5165
5266export function gitBranchSearchInfiniteQueryOptions(input: {
67 environmentId: EnvironmentId | null;
5368 cwd: string | null;
5469 query: string;
5570 enabled?: boolean;
......@@ -57,11 +72,12 @@ export function gitBranchSearchInfiniteQueryOptions(input: {
5772 const normalizedQuery = input.query.trim();
5873
5974 return infiniteQueryOptions({
60 queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery),
75 queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery),
6176 initialPageParam: 0,
6277 queryFn: async ({ pageParam }) => {
63 const api = ensureNativeApi();
6478 if (!input.cwd) throw new Error("Git branches are unavailable.");
79 if (!input.environmentId) throw new Error("Git branches are unavailable.");
80 const api = ensureEnvironmentApi(input.environmentId);
6581 return api.git.listBranches({
6682 cwd: input.cwd,
6783 ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}),
......@@ -79,62 +95,75 @@ export function gitBranchSearchInfiniteQueryOptions(input: {
7995}
8096
8197export function gitResolvePullRequestQueryOptions(input: {
98 environmentId: EnvironmentId | null;
8299 cwd: string | null;
83100 reference: string | null;
84101}) {
85102 return queryOptions({
86 queryKey: ["git", "pull-request", input.cwd, input.reference] as const,
103 queryKey: [
104 "git",
105 "pull-request",
106 input.environmentId ?? null,
107 input.cwd,
108 input.reference,
109 ] as const,
87110 queryFn: async () => {
88 const api = ensureNativeApi();
89 if (!input.cwd || !input.reference) {
111 if (!input.cwd || !input.reference || !input.environmentId) {
90112 throw new Error("Pull request lookup is unavailable.");
91113 }
114 const api = ensureEnvironmentApi(input.environmentId);
92115 return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference });
93116 },
94 enabled: input.cwd !== null && input.reference !== null,
117 enabled: input.environmentId !== null && input.cwd !== null && input.reference !== null,
95118 staleTime: 30_000,
96119 refetchOnWindowFocus: false,
97120 refetchOnReconnect: false,
98121 });
99122}
100123
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}) {
102129 return mutationOptions({
103 mutationKey: gitMutationKeys.init(input.cwd),
130 mutationKey: gitMutationKeys.init(input.environmentId, input.cwd),
104131 mutationFn: async () => {
105 const api = ensureNativeApi();
106 if (!input.cwd) throw new Error("Git init is unavailable.");
132 if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable.");
133 const api = ensureEnvironmentApi(input.environmentId);
107134 return api.git.init({ cwd: input.cwd });
108135 },
109136 onSettled: async () => {
110 await invalidateGitBranchQueries(input.queryClient, input.cwd);
137 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
111138 },
112139 });
113140}
114141
115142export function gitCheckoutMutationOptions(input: {
143 environmentId: EnvironmentId | null;
116144 cwd: string | null;
117145 queryClient: QueryClient;
118146}) {
119147 return mutationOptions({
120 mutationKey: gitMutationKeys.checkout(input.cwd),
148 mutationKey: gitMutationKeys.checkout(input.environmentId, input.cwd),
121149 mutationFn: async (branch: string) => {
122 const api = ensureNativeApi();
123 if (!input.cwd) throw new Error("Git checkout is unavailable.");
150 if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable.");
151 const api = ensureEnvironmentApi(input.environmentId);
124152 return api.git.checkout({ cwd: input.cwd, branch });
125153 },
126154 onSettled: async () => {
127 await invalidateGitBranchQueries(input.queryClient, input.cwd);
155 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
128156 },
129157 });
130158}
131159
132160export function gitRunStackedActionMutationOptions(input: {
161 environmentId: EnvironmentId | null;
133162 cwd: string | null;
134163 queryClient: QueryClient;
135164}) {
136165 return mutationOptions({
137 mutationKey: gitMutationKeys.runStackedAction(input.cwd),
166 mutationKey: gitMutationKeys.runStackedAction(input.environmentId, input.cwd),
138167 mutationFn: async ({
139168 actionId,
140169 action,
......@@ -150,8 +179,8 @@ export function gitRunStackedActionMutationOptions(input: {
150179 filePaths?: string[];
151180 onProgress?: (event: GitActionProgressEvent) => void;
152181 }) => {
153 if (!input.cwd) throw new Error("Git action is unavailable.");
154 return getWsRpcClient().git.runStackedAction(
182 if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable.");
183 return getWsRpcClientForEnvironment(input.environmentId).git.runStackedAction(
155184 {
156185 action,
157186 actionId,
......@@ -164,62 +193,85 @@ export function gitRunStackedActionMutationOptions(input: {
164193 );
165194 },
166195 onSuccess: async () => {
167 await invalidateGitBranchQueries(input.queryClient, input.cwd);
196 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
168197 },
169198 });
170199}
171200
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}) {
173206 return mutationOptions({
174 mutationKey: gitMutationKeys.pull(input.cwd),
207 mutationKey: gitMutationKeys.pull(input.environmentId, input.cwd),
175208 mutationFn: async () => {
176 const api = ensureNativeApi();
177 if (!input.cwd) throw new Error("Git pull is unavailable.");
209 if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable.");
210 const api = ensureEnvironmentApi(input.environmentId);
178211 return api.git.pull({ cwd: input.cwd });
179212 },
180213 onSuccess: async () => {
181 await invalidateGitBranchQueries(input.queryClient, input.cwd);
214 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
182215 },
183216 });
184217}
185218
186export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) {
219export function gitCreateWorktreeMutationOptions(input: {
220 environmentId: EnvironmentId | null;
221 queryClient: QueryClient;
222}) {
187223 return mutationOptions({
188 mutationKey: ["git", "mutation", "create-worktree"] as const,
224 mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const,
189225 mutationFn: (
190 args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["createWorktree"]>[0],
191 ) => ensureNativeApi().git.createWorktree(args),
226 args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["createWorktree"]>[0],
227 ) => {
228 if (!input.environmentId) {
229 throw new Error("Worktree creation is unavailable.");
230 }
231 return ensureEnvironmentApi(input.environmentId).git.createWorktree(args);
232 },
192233 onSuccess: async () => {
193 await invalidateGitQueries(input.queryClient);
234 await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
194235 },
195236 });
196237}
197238
198export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) {
239export function gitRemoveWorktreeMutationOptions(input: {
240 environmentId: EnvironmentId | null;
241 queryClient: QueryClient;
242}) {
199243 return mutationOptions({
200 mutationKey: ["git", "mutation", "remove-worktree"] as const,
244 mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const,
201245 mutationFn: (
202 args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["removeWorktree"]>[0],
203 ) => ensureNativeApi().git.removeWorktree(args),
246 args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["removeWorktree"]>[0],
247 ) => {
248 if (!input.environmentId) {
249 throw new Error("Worktree removal is unavailable.");
250 }
251 return ensureEnvironmentApi(input.environmentId).git.removeWorktree(args);
252 },
204253 onSuccess: async () => {
205 await invalidateGitQueries(input.queryClient);
254 await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
206255 },
207256 });
208257}
209258
210259export function gitPreparePullRequestThreadMutationOptions(input: {
260 environmentId: EnvironmentId | null;
211261 cwd: string | null;
212262 queryClient: QueryClient;
213263}) {
214264 return mutationOptions({
215 mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd),
265 mutationKey: gitMutationKeys.preparePullRequestThread(input.environmentId, input.cwd),
216266 mutationFn: async (args: {
217267 reference: string;
218268 mode: "local" | "worktree";
219269 threadId?: ThreadId;
220270 }) => {
221 const api = ensureNativeApi();
222 if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");
271 if (!input.cwd || !input.environmentId) {
272 throw new Error("Pull request thread preparation is unavailable.");
273 }
274 const api = ensureEnvironmentApi(input.environmentId);
223275 return api.git.preparePullRequestThread({
224276 cwd: input.cwd,
225277 reference: args.reference,
......@@ -228,7 +280,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: {
228280 });
229281 },
230282 onSuccess: async () => {
231 await invalidateGitBranchQueries(input.queryClient, input.cwd);
283 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
232284 },
233285 });
234286}
apps/web/src/lib/gitStatusState.test.ts+47-9
......@@ -1,4 +1,4 @@
1import type { GitStatusResult } from "@t3tools/contracts";
1import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts";
22import { afterEach, describe, expect, it, vi } from "vitest";
33
44import {
......@@ -16,6 +16,10 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even
1616}
1717
1818const 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
2024const BASE_STATUS: GitStatusResult = {
2125 isRepo: true,
......@@ -55,7 +59,7 @@ afterEach(() => {
5559
5660describe("gitStatusState", () => {
5761 it("starts fresh cwd state in a pending state", () => {
58 expect(getGitStatusSnapshot("/fresh")).toEqual({
62 expect(getGitStatusSnapshot(FRESH_TARGET)).toEqual({
5963 data: null,
6064 error: null,
6165 cause: null,
......@@ -64,11 +68,11 @@ describe("gitStatusState", () => {
6468 });
6569
6670 it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => {
67 const releaseA = watchGitStatus("/repo", gitClient);
68 const releaseB = watchGitStatus("/repo", gitClient);
71 const releaseA = watchGitStatus(TARGET, gitClient);
72 const releaseB = watchGitStatus(TARGET, gitClient);
6973
7074 expect(gitClient.onStatus).toHaveBeenCalledOnce();
71 expect(getGitStatusSnapshot("/repo")).toEqual({
75 expect(getGitStatusSnapshot(TARGET)).toEqual({
7276 data: null,
7377 error: null,
7478 cause: null,
......@@ -77,7 +81,7 @@ describe("gitStatusState", () => {
7781
7882 emitGitStatus(BASE_STATUS);
7983
80 expect(getGitStatusSnapshot("/repo")).toEqual({
84 expect(getGitStatusSnapshot(TARGET)).toEqual({
8185 data: BASE_STATUS,
8286 error: null,
8387 cause: null,
......@@ -92,15 +96,15 @@ describe("gitStatusState", () => {
9296 });
9397
9498 it("refreshes git status through the unary RPC without restarting the stream", async () => {
95 const release = watchGitStatus("/repo", gitClient);
99 const release = watchGitStatus(TARGET, gitClient);
96100
97101 emitGitStatus(BASE_STATUS);
98 const refreshed = await refreshGitStatus("/repo", gitClient);
102 const refreshed = await refreshGitStatus(TARGET, gitClient);
99103
100104 expect(gitClient.onStatus).toHaveBeenCalledOnce();
101105 expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
102106 expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" });
103 expect(getGitStatusSnapshot("/repo")).toEqual({
107 expect(getGitStatusSnapshot(TARGET)).toEqual({
104108 data: BASE_STATUS,
105109 error: null,
106110 cause: null,
......@@ -109,4 +113,38 @@ describe("gitStatusState", () => {
109113
110114 release();
111115 });
116
117 it("keeps git status subscriptions isolated by environment when cwds match", () => {
118 const localListeners = new Set<(event: GitStatusResult) => void>();
119 const remoteListeners = new Set<(event: GitStatusResult) => void>();
120 const localClient = {
121 refreshStatus: vi.fn(),
122 onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) =>
123 registerListener(localListeners, listener),
124 ),
125 };
126 const remoteClient = {
127 refreshStatus: vi.fn(),
128 onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) =>
129 registerListener(remoteListeners, listener),
130 ),
131 };
132 const remoteTarget = { environmentId: OTHER_ENVIRONMENT_ID, cwd: "/repo" } as const;
133
134 const releaseLocal = watchGitStatus(TARGET, localClient);
135 const releaseRemote = watchGitStatus(remoteTarget, remoteClient);
136
137 for (const listener of localListeners) {
138 listener(BASE_STATUS);
139 }
140 for (const listener of remoteListeners) {
141 listener({ ...BASE_STATUS, branch: "remote-branch" });
142 }
143
144 expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status");
145 expect(getGitStatusSnapshot(remoteTarget).data?.branch).toBe("remote-branch");
146
147 releaseLocal();
148 releaseRemote();
149 });
112150});
apps/web/src/lib/gitStatusState.ts+78-76
......@@ -1,11 +1,15 @@
11import { 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";
37import { Cause } from "effect";
48import { Atom } from "effect/unstable/reactivity";
59import { useEffect } from "react";
610
711import { appAtomRegistry } from "../rpc/atomRegistry";
8import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient";
12import { getWsRpcClient, getWsRpcClientForEnvironment, type WsRpcClient } from "../wsRpcClient";
913
1014export type GitStatusStreamError = GitManagerServiceError;
1115
......@@ -23,6 +27,11 @@ interface WatchedGitStatus {
2327 unsubscribe: () => void;
2428}
2529
30export interface GitStatusTarget {
31 readonly environmentId: EnvironmentId | null;
32 readonly cwd: string | null;
33}
34
2635const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
2736 data: null,
2837 error: null,
......@@ -40,79 +49,91 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe(
4049
4150const NOOP: () => void = () => undefined;
4251const watchedGitStatuses = new Map<string, WatchedGitStatus>();
43const knownGitStatusCwds = new Set<string>();
52const knownGitStatusKeys = new Set<string>();
4453const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>();
45const gitStatusLastRefreshAtByCwd = new Map<string, number>();
54const gitStatusLastRefreshAtByKey = new Map<string, number>();
4655
4756const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
4857
49let sharedGitStatusClient: GitStatusClient | null = null;
50
51const gitStatusStateAtom = Atom.family((cwd: string) => {
52 knownGitStatusCwds.add(cwd);
58const gitStatusStateAtom = Atom.family((key: string) => {
59 knownGitStatusKeys.add(key);
5360 return Atom.make(INITIAL_GIT_STATUS_STATE).pipe(
5461 Atom.keepAlive,
55 Atom.withLabel(`git-status:${cwd}`),
62 Atom.withLabel(`git-status:${key}`),
5663 );
5764});
5865
59export function getGitStatusSnapshot(cwd: string | null): GitStatusState {
60 if (cwd === null) {
66function getGitStatusTargetKey(target: GitStatusTarget): string | 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) {
6184 return EMPTY_GIT_STATUS_STATE;
6285 }
6386
64 return appAtomRegistry.get(gitStatusStateAtom(cwd));
87 return appAtomRegistry.get(gitStatusStateAtom(targetKey));
6588}
6689
6790export function watchGitStatus(
68 cwd: string | null,
69 client: GitStatusClient = getWsRpcClient().git,
91 target: GitStatusTarget,
92 client: GitStatusClient = resolveGitStatusClient(target),
7093): () => void {
71 if (cwd === null) {
94 const targetKey = getGitStatusTargetKey(target);
95 if (targetKey === null) {
7296 return NOOP;
7397 }
7498
75 ensureGitStatusClient(client);
76
77 const watched = watchedGitStatuses.get(cwd);
99 const watched = watchedGitStatuses.get(targetKey);
78100 if (watched) {
79101 watched.refCount += 1;
80 return () => unwatchGitStatus(cwd);
102 return () => unwatchGitStatus(targetKey);
81103 }
82104
83 watchedGitStatuses.set(cwd, {
105 watchedGitStatuses.set(targetKey, {
84106 refCount: 1,
85 unsubscribe: subscribeToGitStatus(cwd),
107 unsubscribe: subscribeToGitStatus(targetKey, target.cwd!, client),
86108 });
87109
88 return () => unwatchGitStatus(cwd);
110 return () => unwatchGitStatus(targetKey);
89111}
90112
91113export function refreshGitStatus(
92 cwd: string | null,
93 client: GitStatusClient = getWsRpcClient().git,
114 target: GitStatusTarget,
115 client: GitStatusClient = resolveGitStatusClient(target),
94116): Promise<GitStatusResult | null> {
95 if (cwd === null) {
117 const targetKey = getGitStatusTargetKey(target);
118 if (targetKey === null || target.cwd === null) {
96119 return Promise.resolve(null);
97120 }
98121
99 ensureGitStatusClient(client);
100
101 const currentInFlight = gitStatusRefreshInFlight.get(cwd);
122 const currentInFlight = gitStatusRefreshInFlight.get(targetKey);
102123 if (currentInFlight) {
103124 return currentInFlight;
104125 }
105126
106 const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0;
127 const lastRequestedAt = gitStatusLastRefreshAtByKey.get(targetKey) ?? 0;
107128 if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) {
108 return Promise.resolve(getGitStatusSnapshot(cwd).data);
129 return Promise.resolve(getGitStatusSnapshot(target).data);
109130 }
110131
111 gitStatusLastRefreshAtByCwd.set(cwd, Date.now());
112 const refreshPromise = client.refreshStatus({ cwd }).finally(() => {
113 gitStatusRefreshInFlight.delete(cwd);
132 gitStatusLastRefreshAtByKey.set(targetKey, Date.now());
133 const refreshPromise = client.refreshStatus({ cwd: target.cwd }).finally(() => {
134 gitStatusRefreshInFlight.delete(targetKey);
114135 });
115 gitStatusRefreshInFlight.set(cwd, refreshPromise);
136 gitStatusRefreshInFlight.set(targetKey, refreshPromise);
116137 return refreshPromise;
117138}
118139
......@@ -122,43 +143,29 @@ export function resetGitStatusStateForTests(): void {
122143 }
123144 watchedGitStatuses.clear();
124145 gitStatusRefreshInFlight.clear();
125 gitStatusLastRefreshAtByCwd.clear();
126 sharedGitStatusClient = null;
146 gitStatusLastRefreshAtByKey.clear();
127147
128 for (const cwd of knownGitStatusCwds) {
129 appAtomRegistry.set(gitStatusStateAtom(cwd), INITIAL_GIT_STATUS_STATE);
148 for (const key of knownGitStatusKeys) {
149 appAtomRegistry.set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE);
130150 }
131 knownGitStatusCwds.clear();
132}
133
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;
151 knownGitStatusKeys.clear();
139152}
140153
141function ensureGitStatusClient(client: GitStatusClient): void {
142 if (sharedGitStatusClient === client) {
143 return;
144 }
145
146 if (sharedGitStatusClient !== null) {
147 resetLiveGitStatusSubscriptions();
148 }
149
150 sharedGitStatusClient = client;
151}
154export function useGitStatus(target: GitStatusTarget): GitStatusState {
155 const targetKey = getGitStatusTargetKey(target);
156 useEffect(
157 () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }),
158 [target.environmentId, target.cwd],
159 );
152160
153function resetLiveGitStatusSubscriptions(): void {
154 for (const watched of watchedGitStatuses.values()) {
155 watched.unsubscribe();
156 }
157 watchedGitStatuses.clear();
161 const state = useAtomValue(
162 targetKey !== null ? gitStatusStateAtom(targetKey) : EMPTY_GIT_STATUS_ATOM,
163 );
164 return targetKey === null ? EMPTY_GIT_STATUS_STATE : state;
158165}
159166
160function unwatchGitStatus(cwd: string): void {
161 const watched = watchedGitStatuses.get(cwd);
167function unwatchGitStatus(targetKey: string): void {
168 const watched = watchedGitStatuses.get(targetKey);
162169 if (!watched) {
163170 return;
164171 }
......@@ -169,20 +176,15 @@ function unwatchGitStatus(cwd: string): void {
169176 }
170177
171178 watched.unsubscribe();
172 watchedGitStatuses.delete(cwd);
179 watchedGitStatuses.delete(targetKey);
173180}
174181
175function subscribeToGitStatus(cwd: string): () => void {
176 const client = sharedGitStatusClient;
177 if (!client) {
178 return NOOP;
179 }
180
181 markGitStatusPending(cwd);
182function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void {
183 markGitStatusPending(targetKey);
182184 return client.onStatus(
183185 { cwd },
184186 (status) => {
185 appAtomRegistry.set(gitStatusStateAtom(cwd), {
187 appAtomRegistry.set(gitStatusStateAtom(targetKey), {
186188 data: status,
187189 error: null,
188190 cause: null,
......@@ -191,14 +193,14 @@ function subscribeToGitStatus(cwd: string): () => void {
191193 },
192194 {
193195 onResubscribe: () => {
194 markGitStatusPending(cwd);
196 markGitStatusPending(targetKey);
195197 },
196198 },
197199 );
198200}
199201
200function markGitStatusPending(cwd: string): void {
201 const atom = gitStatusStateAtom(cwd);
202function markGitStatusPending(targetKey: string): void {
203 const atom = gitStatusStateAtom(targetKey);
202204 const current = appAtomRegistry.get(atom);
203205 const next =
204206 current.data === null
apps/web/src/lib/projectReactQuery.ts+17-8
......@@ -1,11 +1,15 @@
1import type { ProjectSearchEntriesResult } from "@t3tools/contracts";
1import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts";
22import { queryOptions } from "@tanstack/react-query";
3import { ensureNativeApi } from "~/nativeApi";
3import { ensureEnvironmentApi } from "~/environmentApi";
44
55export const projectQueryKeys = {
66 all: ["projects"] as const,
7 searchEntries: (cwd: string | null, query: string, limit: number) =>
8 ["projects", "search-entries", cwd, query, limit] as const,
7 searchEntries: (
8 environmentId: EnvironmentId | null,
9 cwd: string | null,
10 query: string,
11 limit: number,
12 ) => ["projects", "search-entries", environmentId ?? null, cwd, query, limit] as const,
913};
1014
1115const DEFAULT_SEARCH_ENTRIES_LIMIT = 80;
......@@ -16,6 +20,7 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {
1620};
1721
1822export function projectSearchEntriesQueryOptions(input: {
23 environmentId: EnvironmentId | null;
1924 cwd: string | null;
2025 query: string;
2126 enabled?: boolean;
......@@ -24,19 +29,23 @@ export function projectSearchEntriesQueryOptions(input: {
2429}) {
2530 const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT;
2631 return queryOptions({
27 queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit),
32 queryKey: projectQueryKeys.searchEntries(input.environmentId, input.cwd, input.query, limit),
2833 queryFn: async () => {
29 const api = ensureNativeApi();
30 if (!input.cwd) {
34 if (!input.cwd || !input.environmentId) {
3135 throw new Error("Workspace entry search is unavailable.");
3236 }
37 const api = ensureEnvironmentApi(input.environmentId);
3338 return api.projects.searchEntries({
3439 cwd: input.cwd,
3540 query: input.query,
3641 limit,
3742 });
3843 },
39 enabled: (input.enabled ?? true) && input.cwd !== null && input.query.length > 0,
44 enabled:
45 (input.enabled ?? true) &&
46 input.environmentId !== null &&
47 input.cwd !== null &&
48 input.query.length > 0,
4049 staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,
4150 placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,
4251 });
apps/web/src/lib/providerReactQuery.test.ts+11-4
......@@ -1,21 +1,22 @@
1import { ThreadId, type NativeApi } from "@t3tools/contracts";
1import { EnvironmentId, ThreadId, type EnvironmentApi } from "@t3tools/contracts";
22import { QueryClient } from "@tanstack/react-query";
33import { afterEach, describe, expect, it, vi } from "vitest";
44import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery";
5import * as nativeApi from "../nativeApi";
5import * as environmentApi from "../environmentApi";
66
77const threadId = ThreadId.makeUnsafe("thread-id");
8const environmentId = EnvironmentId.makeUnsafe("environment-local");
89
910function mockNativeApi(input: {
1011 getTurnDiff: ReturnType<typeof vi.fn>;
1112 getFullThreadDiff: ReturnType<typeof vi.fn>;
1213}) {
13 vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({
14 vi.spyOn(environmentApi, "ensureEnvironmentApi").mockReturnValue({
1415 orchestration: {
1516 getTurnDiff: input.getTurnDiff,
1617 getFullThreadDiff: input.getFullThreadDiff,
1718 },
18 } as unknown as NativeApi);
19 } as unknown as EnvironmentApi);
1920}
2021
2122afterEach(() => {
......@@ -25,6 +26,7 @@ afterEach(() => {
2526describe("providerQueryKeys.checkpointDiff", () => {
2627 it("includes cacheScope so reused turn counts do not collide", () => {
2728 const baseInput = {
29 environmentId,
2830 threadId,
2931 fromTurnCount: 1,
3032 toTurnCount: 2,
......@@ -51,6 +53,7 @@ describe("checkpointDiffQueryOptions", () => {
5153 mockNativeApi({ getTurnDiff, getFullThreadDiff });
5254
5355 const options = checkpointDiffQueryOptions({
56 environmentId,
5457 threadId,
5558 fromTurnCount: 3,
5659 toTurnCount: 4,
......@@ -74,6 +77,7 @@ describe("checkpointDiffQueryOptions", () => {
7477 mockNativeApi({ getTurnDiff, getFullThreadDiff });
7578
7679 const options = checkpointDiffQueryOptions({
80 environmentId,
7781 threadId,
7882 fromTurnCount: 0,
7983 toTurnCount: 2,
......@@ -96,6 +100,7 @@ describe("checkpointDiffQueryOptions", () => {
96100 mockNativeApi({ getTurnDiff, getFullThreadDiff });
97101
98102 const options = checkpointDiffQueryOptions({
103 environmentId,
99104 threadId,
100105 fromTurnCount: 4,
101106 toTurnCount: 3,
......@@ -113,6 +118,7 @@ describe("checkpointDiffQueryOptions", () => {
113118
114119 it("retries checkpoint-not-ready errors longer than generic failures", () => {
115120 const options = checkpointDiffQueryOptions({
121 environmentId,
116122 threadId,
117123 fromTurnCount: 1,
118124 toTurnCount: 2,
......@@ -137,6 +143,7 @@ describe("checkpointDiffQueryOptions", () => {
137143
138144 it("backs off longer for checkpoint-not-ready errors", () => {
139145 const options = checkpointDiffQueryOptions({
146 environmentId,
140147 threadId,
141148 fromTurnCount: 1,
142149 toTurnCount: 2,
apps/web/src/lib/providerReactQuery.ts+11-4
......@@ -1,13 +1,15 @@
11import {
2 type EnvironmentId,
23 OrchestrationGetFullThreadDiffInput,
34 OrchestrationGetTurnDiffInput,
45 ThreadId,
56} from "@t3tools/contracts";
67import { queryOptions } from "@tanstack/react-query";
78import { Option, Schema } from "effect";
8import { ensureNativeApi } from "../nativeApi";
9import { ensureEnvironmentApi } from "../environmentApi";
910
1011interface CheckpointDiffQueryInput {
12 environmentId: EnvironmentId | null;
1113 threadId: ThreadId | null;
1214 fromTurnCount: number | null;
1315 toTurnCount: number | null;
......@@ -21,6 +23,7 @@ export const providerQueryKeys = {
2123 [
2224 "providers",
2325 "checkpointDiff",
26 input.environmentId ?? null,
2427 input.threadId,
2528 input.fromTurnCount,
2629 input.toTurnCount,
......@@ -95,10 +98,10 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) {
9598 return queryOptions({
9699 queryKey: providerQueryKeys.checkpointDiff(input),
97100 queryFn: async () => {
98 const api = ensureNativeApi();
99 if (!input.threadId || decodedRequest._tag === "None") {
101 if (!input.environmentId || !input.threadId || decodedRequest._tag === "None") {
100102 throw new Error("Checkpoint diff is unavailable.");
101103 }
104 const api = ensureEnvironmentApi(input.environmentId);
102105 try {
103106 if (decodedRequest.value.kind === "fullThreadDiff") {
104107 return await api.orchestration.getFullThreadDiff(decodedRequest.value.input);
......@@ -108,7 +111,11 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) {
108111 throw new Error(normalizeCheckpointErrorMessage(error), { cause: error });
109112 }
110113 },
111 enabled: (input.enabled ?? true) && !!input.threadId && decodedRequest._tag === "Some",
114 enabled:
115 (input.enabled ?? true) &&
116 !!input.environmentId &&
117 !!input.threadId &&
118 decodedRequest._tag === "Some",
112119 staleTime: Infinity,
113120 retry: (failureCount, error) => {
114121 if (isCheckpointTemporarilyUnavailable(error)) {
apps/web/src/lib/terminalStateCleanup.test.ts+20-13
......@@ -1,58 +1,65 @@
1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
12import { ThreadId } from "@t3tools/contracts";
23import { describe, expect, it } from "vitest";
34
45import { collectActiveTerminalThreadIds } from "./terminalStateCleanup";
56
67const threadId = (id: string): ThreadId => ThreadId.makeUnsafe(id);
8const threadKey = (environmentId: string, id: string): string =>
9 scopedThreadKey(scopeThreadRef(environmentId as never, threadId(id)));
710
811describe("collectActiveTerminalThreadIds", () => {
912 it("retains non-deleted server threads", () => {
1013 const activeThreadIds = collectActiveTerminalThreadIds({
1114 snapshotThreads: [
12 { id: threadId("server-1"), deletedAt: null, archivedAt: null },
13 { id: threadId("server-2"), deletedAt: null, archivedAt: null },
15 { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null },
16 { key: threadKey("env-b", "server-2"), deletedAt: null, archivedAt: null },
1417 ],
15 draftThreadIds: [],
18 draftThreadKeys: [],
1619 });
1720
18 expect(activeThreadIds).toEqual(new Set([threadId("server-1"), threadId("server-2")]));
21 expect(activeThreadIds).toEqual(
22 new Set([threadKey("env-a", "server-1"), threadKey("env-b", "server-2")]),
23 );
1924 });
2025
2126 it("ignores deleted and archived server threads and keeps local draft threads", () => {
2227 const activeThreadIds = collectActiveTerminalThreadIds({
2328 snapshotThreads: [
24 { id: threadId("server-active"), deletedAt: null, archivedAt: null },
29 { key: threadKey("env-a", "server-active"), deletedAt: null, archivedAt: null },
2530 {
26 id: threadId("server-deleted"),
31 key: threadKey("env-a", "server-deleted"),
2732 deletedAt: "2026-03-05T08:00:00.000Z",
2833 archivedAt: null,
2934 },
3035 {
31 id: threadId("server-archived"),
36 key: threadKey("env-a", "server-archived"),
3237 deletedAt: null,
3338 archivedAt: "2026-03-05T09:00:00.000Z",
3439 },
3540 ],
36 draftThreadIds: [threadId("local-draft")],
41 draftThreadKeys: [threadKey("env-a", "local-draft")],
3742 });
3843
39 expect(activeThreadIds).toEqual(new Set([threadId("server-active"), threadId("local-draft")]));
44 expect(activeThreadIds).toEqual(
45 new Set([threadKey("env-a", "server-active"), threadKey("env-a", "local-draft")]),
46 );
4047 });
4148
4249 it("does not keep draft-linked terminal state for archived server threads", () => {
43 const archivedThreadId = threadId("server-archived");
50 const archivedThreadId = threadKey("env-a", "server-archived");
4451
4552 const activeThreadIds = collectActiveTerminalThreadIds({
4653 snapshotThreads: [
4754 {
48 id: archivedThreadId,
55 key: archivedThreadId,
4956 deletedAt: null,
5057 archivedAt: "2026-03-05T09:00:00.000Z",
5158 },
5259 ],
53 draftThreadIds: [archivedThreadId, threadId("local-draft")],
60 draftThreadKeys: [archivedThreadId, threadKey("env-a", "local-draft")],
5461 });
5562
56 expect(activeThreadIds).toEqual(new Set([threadId("local-draft")]));
63 expect(activeThreadIds).toEqual(new Set([threadKey("env-a", "local-draft")]));
5764 });
5865});
apps/web/src/lib/terminalStateCleanup.ts+9-11
......@@ -1,35 +1,33 @@
1import type { ThreadId } from "@t3tools/contracts";
2
31interface TerminalRetentionThread {
4 id: ThreadId;
2 key: string;
53 deletedAt: string | null;
64 archivedAt: string | null;
75}
86
97interface CollectActiveTerminalThreadIdsInput {
108 snapshotThreads: readonly TerminalRetentionThread[];
11 draftThreadIds: Iterable<ThreadId>;
9 draftThreadKeys: Iterable<string>;
1210}
1311
1412export function collectActiveTerminalThreadIds(
1513 input: CollectActiveTerminalThreadIdsInput,
16): Set<ThreadId> {
17 const activeThreadIds = new Set<ThreadId>();
18 const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.id, thread]));
14): Set<string> {
15 const activeThreadIds = new Set<string>();
16 const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread]));
1917 for (const thread of input.snapshotThreads) {
2018 if (thread.deletedAt !== null) continue;
2119 if (thread.archivedAt !== null) continue;
22 activeThreadIds.add(thread.id);
20 activeThreadIds.add(thread.key);
2321 }
24 for (const draftThreadId of input.draftThreadIds) {
25 const snapshotThread = snapshotThreadById.get(draftThreadId);
22 for (const draftThreadKey of input.draftThreadKeys) {
23 const snapshotThread = snapshotThreadById.get(draftThreadKey);
2624 if (
2725 snapshotThread &&
2826 (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null)
2927 ) {
3028 continue;
3129 }
32 activeThreadIds.add(draftThreadId);
30 activeThreadIds.add(draftThreadKey);
3331 }
3432 return activeThreadIds;
3533}
apps/web/src/lib/utils.test.ts+41-1
......@@ -1,6 +1,15 @@
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
311import { isWindowsPlatform } from "./utils";
12import { resolveServerUrl } from "./utils";
413
514describe("isWindowsPlatform", () => {
615 it("matches Windows platform identifiers", () => {
......@@ -13,3 +22,34 @@ describe("isWindowsPlatform", () => {
1322 assert.isFalse(isWindowsPlatform("darwin"));
1423 });
1524});
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";
44import { twMerge } from "tailwind-merge";
55import * as Random from "effect/Random";
66import * as Effect from "effect/Effect";
7import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap";
8import { DraftId } from "../composerDraftStore";
79
810export function cn(...inputs: CxOptions) {
911 return twMerge(cx(inputs));
......@@ -34,17 +36,11 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID());
3436
3537export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());
3638
39export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID());
40
3741export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());
3842
3943const 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
4945export const resolveServerUrl = (options?: {
5046 url?: string | undefined;
......@@ -52,12 +48,9 @@ export const resolveServerUrl = (options?: {
5248 pathname?: string | undefined;
5349 searchParams?: Record<string, string> | undefined;
5450}): string => {
55 const rawUrl = firstNonEmptyString(
56 options?.url,
57 window.desktopBridge?.getWsUrl(),
58 import.meta.env.VITE_WS_URL,
59 window.location.origin,
60 );
51 const rawUrl = isNonEmptyString(options?.url)
52 ? options.url
53 : resolvePrimaryEnvironmentBootstrapUrl();
6154
6255 const parsedUrl = new URL(rawUrl);
6356 if (options?.protocol) {
apps/web/src/localApi.test.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", () => {
6868 }),
6969 ]);
7070
71 expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]);
71 expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]);
7272 expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]);
7373 expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]);
7474 expect(effects.needsProviderInvalidation).toBe(false);
......@@ -106,7 +106,7 @@ describe("deriveOrchestrationBatchEffects", () => {
106106 }),
107107 ]);
108108
109 expect(effects.clearPromotedDraftThreadIds).toEqual([threadId]);
109 expect(effects.promoteDraftThreadIds).toEqual([threadId]);
110110 expect(effects.clearDeletedThreadIds).toEqual([]);
111111 expect(effects.removeTerminalStateThreadIds).toEqual([]);
112112 expect(effects.needsProviderInvalidation).toBe(true);
......@@ -127,7 +127,7 @@ describe("deriveOrchestrationBatchEffects", () => {
127127 }),
128128 ]);
129129
130 expect(effects.clearPromotedDraftThreadIds).toEqual([]);
130 expect(effects.promoteDraftThreadIds).toEqual([]);
131131 expect(effects.clearDeletedThreadIds).toEqual([]);
132132 expect(effects.removeTerminalStateThreadIds).toEqual([]);
133133 });
apps/web/src/orchestrationEventEffects.ts+4-4
......@@ -1,7 +1,7 @@
11import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts";
22
33export interface OrchestrationBatchEffects {
4 clearPromotedDraftThreadIds: ThreadId[];
4 promoteDraftThreadIds: ThreadId[];
55 clearDeletedThreadIds: ThreadId[];
66 removeTerminalStateThreadIds: ThreadId[];
77 needsProviderInvalidation: boolean;
......@@ -70,12 +70,12 @@ export function deriveOrchestrationBatchEffects(
7070 }
7171 }
7272
73 const clearPromotedDraftThreadIds: ThreadId[] = [];
73 const promoteDraftThreadIds: ThreadId[] = [];
7474 const clearDeletedThreadIds: ThreadId[] = [];
7575 const removeTerminalStateThreadIds: ThreadId[] = [];
7676 for (const [threadId, effect] of threadLifecycleEffects) {
7777 if (effect.clearPromotedDraft) {
78 clearPromotedDraftThreadIds.push(threadId);
78 promoteDraftThreadIds.push(threadId);
7979 }
8080 if (effect.clearDeletedThread) {
8181 clearDeletedThreadIds.push(threadId);
......@@ -86,7 +86,7 @@ export function deriveOrchestrationBatchEffects(
8686 }
8787
8888 return {
89 clearPromotedDraftThreadIds,
89 promoteDraftThreadIds,
9090 clearDeletedThreadIds,
9191 removeTerminalStateThreadIds,
9292 needsProviderInvalidation,
apps/web/src/routeTree.gen.ts+39-17
......@@ -14,7 +14,8 @@ import { Route as ChatRouteImport } from './routes/_chat'
1414import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1515import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
1616import { 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
1920const SettingsRoute = SettingsRouteImport.update({
2021 id: '/settings',
......@@ -40,58 +41,70 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
4041 path: '/archived',
4142 getParentRoute: () => SettingsRoute,
4243} as any)
43const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
44 id: '/$threadId',
45 path: '/$threadId',
44const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({
45 id: '/draft/$draftId',
46 path: '/draft/$draftId',
4647 getParentRoute: () => ChatRoute,
4748} as any)
49const ChatEnvironmentIdThreadIdRoute =
50 ChatEnvironmentIdThreadIdRouteImport.update({
51 id: '/$environmentId/$threadId',
52 path: '/$environmentId/$threadId',
53 getParentRoute: () => ChatRoute,
54 } as any)
4855
4956export interface FileRoutesByFullPath {
5057 '/': typeof ChatIndexRoute
5158 '/settings': typeof SettingsRouteWithChildren
52 '/$threadId': typeof ChatThreadIdRoute
5359 '/settings/archived': typeof SettingsArchivedRoute
5460 '/settings/general': typeof SettingsGeneralRoute
61 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
62 '/draft/$draftId': typeof ChatDraftDraftIdRoute
5563}
5664export interface FileRoutesByTo {
5765 '/settings': typeof SettingsRouteWithChildren
58 '/$threadId': typeof ChatThreadIdRoute
5966 '/settings/archived': typeof SettingsArchivedRoute
6067 '/settings/general': typeof SettingsGeneralRoute
6168 '/': typeof ChatIndexRoute
69 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
70 '/draft/$draftId': typeof ChatDraftDraftIdRoute
6271}
6372export interface FileRoutesById {
6473 __root__: typeof rootRouteImport
6574 '/_chat': typeof ChatRouteWithChildren
6675 '/settings': typeof SettingsRouteWithChildren
67 '/_chat/$threadId': typeof ChatThreadIdRoute
6876 '/settings/archived': typeof SettingsArchivedRoute
6977 '/settings/general': typeof SettingsGeneralRoute
7078 '/_chat/': typeof ChatIndexRoute
79 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
80 '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute
7181}
7282export interface FileRouteTypes {
7383 fileRoutesByFullPath: FileRoutesByFullPath
7484 fullPaths:
7585 | '/'
7686 | '/settings'
77 | '/$threadId'
7887 | '/settings/archived'
7988 | '/settings/general'
89 | '/$environmentId/$threadId'
90 | '/draft/$draftId'
8091 fileRoutesByTo: FileRoutesByTo
8192 to:
8293 | '/settings'
83 | '/$threadId'
8494 | '/settings/archived'
8595 | '/settings/general'
8696 | '/'
97 | '/$environmentId/$threadId'
98 | '/draft/$draftId'
8799 id:
88100 | '__root__'
89101 | '/_chat'
90102 | '/settings'
91 | '/_chat/$threadId'
92103 | '/settings/archived'
93104 | '/settings/general'
94105 | '/_chat/'
106 | '/_chat/$environmentId/$threadId'
107 | '/_chat/draft/$draftId'
95108 fileRoutesById: FileRoutesById
96109}
97110export interface RootRouteChildren {
......@@ -136,24 +149,33 @@ declare module '@tanstack/react-router' {
136149 preLoaderRoute: typeof SettingsArchivedRouteImport
137150 parentRoute: typeof SettingsRoute
138151 }
139 '/_chat/$threadId': {
140 id: '/_chat/$threadId'
141 path: '/$threadId'
142 fullPath: '/$threadId'
143 preLoaderRoute: typeof ChatThreadIdRouteImport
152 '/_chat/draft/$draftId': {
153 id: '/_chat/draft/$draftId'
154 path: '/draft/$draftId'
155 fullPath: '/draft/$draftId'
156 preLoaderRoute: typeof ChatDraftDraftIdRouteImport
157 parentRoute: typeof ChatRoute
158 }
159 '/_chat/$environmentId/$threadId': {
160 id: '/_chat/$environmentId/$threadId'
161 path: '/$environmentId/$threadId'
162 fullPath: '/$environmentId/$threadId'
163 preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport
144164 parentRoute: typeof ChatRoute
145165 }
146166 }
147167}
148168
149169interface ChatRouteChildren {
150 ChatThreadIdRoute: typeof ChatThreadIdRoute
151170 ChatIndexRoute: typeof ChatIndexRoute
171 ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute
172 ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute
152173}
153174
154175const ChatRouteChildren: ChatRouteChildren = {
155 ChatThreadIdRoute: ChatThreadIdRoute,
156176 ChatIndexRoute: ChatIndexRoute,
177 ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute,
178 ChatDraftDraftIdRoute: ChatDraftDraftIdRoute,
157179}
158180
159181const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
apps/web/src/routes/__root.tsx+319-166
......@@ -1,8 +1,15 @@
11import {
2 type EnvironmentId,
23 OrchestrationEvent,
34 type ServerLifecycleWelcomePayload,
45 ThreadId,
56} from "@t3tools/contracts";
7import {
8 scopedProjectKey,
9 scopedThreadKey,
10 scopeProjectRef,
11 scopeThreadRef,
12} from "@t3tools/client-runtime";
613import {
714 Outlet,
815 createRootRouteWithContext,
......@@ -10,7 +17,7 @@ import {
1017 useNavigate,
1118 useLocation,
1219} from "@tanstack/react-router";
13import { useEffect, useEffectEvent, useRef } from "react";
20import { useEffect, useEffectEvent, useRef, useState } from "react";
1421import { QueryClient, useQueryClient } from "@tanstack/react-query";
1522import { Throttler } from "@tanstack/react-pacer";
1623
......@@ -24,7 +31,7 @@ import {
2431import { Button } from "../components/ui/button";
2532import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
2633import { resolveAndPersistPreferredEditor } from "../editorPreferences";
27import { readNativeApi } from "../nativeApi";
34import { readLocalApi } from "../localApi";
2835import {
2936 getServerConfigUpdatedNotification,
3037 ServerConfigUpdatedNotification,
......@@ -34,11 +41,15 @@ import {
3441 useServerWelcomeSubscription,
3542} from "../rpc/serverState";
3643import {
37 clearPromotedDraftThread,
38 clearPromotedDraftThreads,
44 markPromotedDraftThreadByRef,
45 markPromotedDraftThreadsByRef,
3946 useComposerDraftStore,
4047} from "../composerDraftStore";
41import { selectProjects, selectThreadById, selectThreads, useStore } from "../store";
48import {
49 selectProjectsAcrossEnvironments,
50 selectThreadsAcrossEnvironments,
51 useStore,
52} from "../store";
4253import { useUiStateStore } from "../uiStateStore";
4354import { useTerminalStateStore } from "../terminalStateStore";
4455import { migrateLocalSettingsToServer } from "../hooks/useSettings";
......@@ -48,7 +59,15 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
4859import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";
4960import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";
5061import { 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
5372export const Route = createRootRouteWithContext<{
5473 queryClient: QueryClient;
......@@ -61,7 +80,7 @@ export const Route = createRootRouteWithContext<{
6180});
6281
6382function RootRouteView() {
64 if (!readNativeApi()) {
83 if (!readLocalApi()) {
6584 return (
6685 <div className="flex h-screen flex-col bg-background text-foreground">
6786 <div className="flex flex-1 items-center justify-center">
......@@ -201,14 +220,24 @@ function coalesceOrchestrationUiEvents(
201220const REPLAY_RECOVERY_RETRY_DELAY_MS = 100;
202221const 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
204232function ServerStateBootstrap() {
205 useEffect(() => startServerStateSync(getWsRpcClient().server), []);
233 useEffect(() => startServerStateSync(getPrimaryWsRpcClientEntry().client.server), []);
206234
207235 return null;
208236}
209237
210238function EventRouter() {
211239 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);
240 const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId);
212241 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
213242 const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded);
214243 const syncProjects = useUiStateStore((store) => store.syncProjects);
......@@ -226,15 +255,22 @@ function EventRouter() {
226255 const handledBootstrapThreadIdRef = useRef<string | null>(null);
227256 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);
228257 const disposedRef = useRef(false);
229 const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined);
258 const bootstrapFromSnapshotRef = useRef<(environmentId: EnvironmentId) => Promise<void>>(
259 async () => undefined,
260 );
261 const schedulePendingDomainEventFlushRef = useRef<() => void>(() => undefined);
230262 const serverConfig = useServerConfig();
263 const clientEntries = useRegisteredWsRpcClientEntries();
231264
232265 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {
233266 if (!payload) return;
234267
268 bindPrimaryWsRpcClientEnvironment(payload.environment.environmentId);
269 setActiveEnvironmentId(payload.environment.environmentId);
270 schedulePendingDomainEventFlushRef.current();
235271 migrateLocalSettingsToServer();
236272 void (async () => {
237 await bootstrapFromSnapshotRef.current();
273 await bootstrapFromSnapshotRef.current(payload.environment.environmentId);
238274 if (disposedRef.current) {
239275 return;
240276 }
......@@ -242,7 +278,12 @@ function EventRouter() {
242278 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
243279 return;
244280 }
245 setProjectExpanded(payload.bootstrapProjectId, true);
281 setProjectExpanded(
282 scopedProjectKey(
283 scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),
284 ),
285 true,
286 );
246287
247288 if (readPathname() !== "/") {
248289 return;
......@@ -251,8 +292,11 @@ function EventRouter() {
251292 return;
252293 }
253294 await navigate({
254 to: "/$threadId",
255 params: { threadId: payload.bootstrapThreadId },
295 to: "/$environmentId/$threadId",
296 params: {
297 environmentId: payload.environment.environmentId,
298 threadId: payload.bootstrapThreadId,
299 },
256300 replace: true,
257301 });
258302 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
......@@ -289,7 +333,7 @@ function EventRouter() {
289333 actionProps: {
290334 children: "Open keybindings.json",
291335 onClick: () => {
292 const api = readNativeApi();
336 const api = readLocalApi();
293337 if (!api) {
294338 return;
295339 }
......@@ -317,40 +361,49 @@ function EventRouter() {
317361 );
318362
319363 useEffect(() => {
320 const api = readNativeApi();
321 if (!api) return;
364 if (!serverConfig) {
365 return;
366 }
367
368 bindPrimaryWsRpcClientEnvironment(serverConfig.environment.environmentId);
369 setActiveEnvironmentId(serverConfig.environment.environmentId);
370 schedulePendingDomainEventFlushRef.current();
371 }, [serverConfig, setActiveEnvironmentId]);
372
373 useEffect(() => {
322374 let disposed = false;
323375 disposedRef.current = false;
324 const recovery = createOrchestrationRecoveryCoordinator();
325 let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null;
326376 let needsProviderInvalidation = false;
327 const pendingDomainEvents: OrchestrationEvent[] = [];
328 let flushPendingDomainEventsScheduled = false;
377 const primaryClientKey = getPrimaryWsRpcClientEntry().key;
329378
330379 const reconcileSnapshotDerivedState = () => {
331380 const storeState = useStore.getState();
332 const threads = selectThreads(storeState);
333 const projects = selectProjects(storeState);
334 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));
381 const threads = selectThreadsAcrossEnvironments(storeState);
382 const projects = selectProjectsAcrossEnvironments(storeState);
383 syncProjects(
384 projects.map((project) => ({
385 key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
386 cwd: project.cwd,
387 })),
388 );
335389 syncThreads(
336390 threads.map((thread) => ({
337 id: thread.id,
391 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
338392 seedVisitedAt: thread.updatedAt ?? thread.createdAt,
339393 })),
340394 );
341 clearPromotedDraftThreads(threads.map((thread) => thread.id));
342 const draftThreadIds = Object.keys(
343 useComposerDraftStore.getState().draftThreadsByThreadId,
344 ) as ThreadId[];
345 const activeThreadIds = collectActiveTerminalThreadIds({
395 markPromotedDraftThreadsByRef(
396 threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)),
397 );
398 const activeThreadKeys = collectActiveTerminalThreadIds({
346399 snapshotThreads: threads.map((thread) => ({
347 id: thread.id,
400 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
348401 deletedAt: null,
349402 archivedAt: thread.archivedAt,
350403 })),
351 draftThreadIds,
404 draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(),
352405 });
353 removeOrphanedTerminalStates(activeThreadIds);
406 removeOrphanedTerminalStates(activeThreadKeys);
354407 };
355408
356409 const queryInvalidationThrottler = new Throttler(
......@@ -371,7 +424,11 @@ function EventRouter() {
371424 },
372425 );
373426
374 const applyEventBatch = (events: ReadonlyArray<OrchestrationEvent>) => {
427 const applyEventBatch = (
428 events: ReadonlyArray<OrchestrationEvent>,
429 environmentId: EnvironmentId,
430 recovery: ReturnType<typeof createOrchestrationRecoveryCoordinator>,
431 ) => {
375432 const nextEvents = recovery.markEventBatchApplied(events);
376433 if (nextEvents.length === 0) {
377434 return;
......@@ -391,189 +448,284 @@ function EventRouter() {
391448 void queryInvalidationThrottler.maybeExecute();
392449 }
393450
394 applyOrchestrationEvents(uiEvents);
451 applyOrchestrationEvents(uiEvents, environmentId);
395452 if (needsProjectUiSync) {
396 const projects = selectProjects(useStore.getState());
397 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));
453 const projects = selectProjectsAcrossEnvironments(useStore.getState());
454 syncProjects(
455 projects.map((project) => ({
456 key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
457 cwd: project.cwd,
458 })),
459 );
398460 }
399461 const needsThreadUiSync = nextEvents.some(
400462 (event) => event.type === "thread.created" || event.type === "thread.deleted",
401463 );
402464 if (needsThreadUiSync) {
403 const threads = selectThreads(useStore.getState());
465 const threads = selectThreadsAcrossEnvironments(useStore.getState());
404466 syncThreads(
405467 threads.map((thread) => ({
406 id: thread.id,
468 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
407469 seedVisitedAt: thread.updatedAt ?? thread.createdAt,
408470 })),
409471 );
410472 }
411473 const draftStore = useComposerDraftStore.getState();
412 for (const threadId of batchEffects.clearPromotedDraftThreadIds) {
413 clearPromotedDraftThread(threadId);
474 for (const threadId of batchEffects.promoteDraftThreadIds) {
475 markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId));
414476 }
415477 for (const threadId of batchEffects.clearDeletedThreadIds) {
416 draftStore.clearDraftThread(threadId);
417 clearThreadUi(threadId);
478 draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId));
479 clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId)));
418480 }
419481 for (const threadId of batchEffects.removeTerminalStateThreadIds) {
420 removeTerminalState(threadId);
482 removeTerminalState(scopeThreadRef(environmentId, threadId));
421483 }
422484 };
423 const flushPendingDomainEvents = () => {
424 flushPendingDomainEventsScheduled = false;
425 if (disposed || pendingDomainEvents.length === 0) {
426 return;
427 }
485 const clientContexts = clientEntries.map((entry) => {
486 const recovery = createOrchestrationRecoveryCoordinator();
487 let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null;
488 const pendingDomainEvents: OrchestrationEvent[] = [];
489 let flushPendingDomainEventsScheduled = false;
490 let boundEnvironmentId = entry.environmentId;
491
492 const bindEnvironmentId = (environmentId: EnvironmentId) => {
493 if (boundEnvironmentId === environmentId) {
494 return;
495 }
496 boundEnvironmentId = environmentId;
497 bindWsRpcClientEntryEnvironment(entry.key, environmentId);
498 schedulePendingDomainEventFlush();
499 };
428500
429 const events = pendingDomainEvents.splice(0, pendingDomainEvents.length);
430 applyEventBatch(events);
431 };
432 const schedulePendingDomainEventFlush = () => {
433 if (flushPendingDomainEventsScheduled) {
434 return;
435 }
501 const flushPendingDomainEvents = () => {
502 flushPendingDomainEventsScheduled = false;
503 if (disposed || pendingDomainEvents.length === 0 || boundEnvironmentId === null) {
504 return;
505 }
436506
437 flushPendingDomainEventsScheduled = true;
438 queueMicrotask(flushPendingDomainEvents);
439 };
507 const events = pendingDomainEvents.splice(0, pendingDomainEvents.length);
508 applyEventBatch(events, boundEnvironmentId, recovery);
509 };
440510
441 const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => {
442 if (!recovery.beginReplayRecovery(reason)) {
443 return;
444 }
511 const schedulePendingDomainEventFlush = () => {
512 if (flushPendingDomainEventsScheduled) {
513 return;
514 }
445515
446 const fromSequenceExclusive = recovery.getState().latestSequence;
447 try {
448 const events = await api.orchestration.replayEvents(fromSequenceExclusive);
449 if (!disposed) {
450 applyEventBatch(events);
516 flushPendingDomainEventsScheduled = true;
517 queueMicrotask(flushPendingDomainEvents);
518 };
519
520 const runSnapshotRecovery = async (
521 reason: "bootstrap" | "replay-failed",
522 environmentId: EnvironmentId,
523 ): Promise<void> => {
524 const started = recovery.beginSnapshotRecovery(reason);
525 if (import.meta.env.MODE !== "test") {
526 const state = recovery.getState();
527 console.info("[orchestration-recovery]", "Snapshot recovery requested.", {
528 reason,
529 clientKey: entry.key,
530 environmentId,
531 skipped: !started,
532 ...(started
533 ? {}
534 : {
535 blockedBy: state.inFlight?.kind ?? null,
536 blockedByReason: state.inFlight?.reason ?? null,
537 }),
538 state,
539 });
540 }
541 if (!started) {
542 return;
451543 }
452 } catch {
453 replayRetryTracker = null;
454 recovery.failReplayRecovery();
455 void fallbackToSnapshotRecovery();
456 return;
457 }
458544
459 if (!disposed) {
460 const replayCompletion = recovery.completeReplayRecovery();
461 const retryDecision = deriveReplayRetryDecision({
462 previousTracker: replayRetryTracker,
463 completion: replayCompletion,
464 recoveryState: recovery.getState(),
465 baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS,
466 maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES,
467 });
468 replayRetryTracker = retryDecision.tracker;
545 try {
546 const snapshot = await entry.client.orchestration.getSnapshot();
547 if (!disposed) {
548 bindEnvironmentId(environmentId);
549 syncServerReadModel(snapshot, environmentId);
550 reconcileSnapshotDerivedState();
551 if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {
552 void runReplayRecovery("sequence-gap");
553 }
554 }
555 } catch {
556 recovery.failSnapshotRecovery();
557 }
558 };
469559
470 if (retryDecision.shouldRetry) {
471 if (retryDecision.delayMs > 0) {
472 await new Promise<void>((resolve) => {
473 setTimeout(resolve, retryDecision.delayMs);
474 });
475 if (disposed) {
560 const fallbackToSnapshotRecovery = async (): Promise<void> => {
561 if (boundEnvironmentId === null) {
562 return;
563 }
564 await runSnapshotRecovery("replay-failed", boundEnvironmentId);
565 };
566
567 const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => {
568 if (!recovery.beginReplayRecovery(reason)) {
569 return;
570 }
571
572 const fromSequenceExclusive = recovery.getState().latestSequence;
573 try {
574 const events = await entry.client.orchestration.replayEvents({ fromSequenceExclusive });
575 if (!disposed) {
576 if (boundEnvironmentId === null) {
577 replayRetryTracker = null;
578 recovery.failReplayRecovery();
476579 return;
477580 }
581 applyEventBatch(events, boundEnvironmentId, recovery);
478582 }
479 void runReplayRecovery(reason);
480 } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") {
481 console.warn(
482 "[orchestration-recovery]",
483 "Stopping replay recovery after no-progress retries.",
484 {
485 state: recovery.getState(),
486 },
487 );
583 } catch {
584 replayRetryTracker = null;
585 recovery.failReplayRecovery();
586 void fallbackToSnapshotRecovery();
587 return;
488588 }
489 }
490 };
491
492 const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise<void> => {
493 const started = recovery.beginSnapshotRecovery(reason);
494 if (import.meta.env.MODE !== "test") {
495 const state = recovery.getState();
496 console.info("[orchestration-recovery]", "Snapshot recovery requested.", {
497 reason,
498 skipped: !started,
499 ...(started
500 ? {}
501 : {
502 blockedBy: state.inFlight?.kind ?? null,
503 blockedByReason: state.inFlight?.reason ?? null,
504 }),
505 state,
506 });
507 }
508 if (!started) {
509 return;
510 }
511589
512 try {
513 const snapshot = await api.orchestration.getSnapshot();
514590 if (!disposed) {
515 syncServerReadModel(snapshot);
516 reconcileSnapshotDerivedState();
517 if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {
518 void runReplayRecovery("sequence-gap");
591 const replayCompletion = recovery.completeReplayRecovery();
592 const retryDecision = deriveReplayRetryDecision({
593 previousTracker: replayRetryTracker,
594 completion: replayCompletion,
595 recoveryState: recovery.getState(),
596 baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS,
597 maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES,
598 });
599 replayRetryTracker = retryDecision.tracker;
600
601 if (retryDecision.shouldRetry) {
602 if (retryDecision.delayMs > 0) {
603 await new Promise<void>((resolve) => {
604 setTimeout(resolve, retryDecision.delayMs);
605 });
606 if (disposed) {
607 return;
608 }
609 }
610 void runReplayRecovery(reason);
611 } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") {
612 console.warn(
613 "[orchestration-recovery]",
614 "Stopping replay recovery after no-progress retries.",
615 {
616 clientKey: entry.key,
617 environmentId: boundEnvironmentId,
618 state: recovery.getState(),
619 },
620 );
519621 }
520622 }
521 } catch {
522 // Keep prior state and wait for welcome or a later replay attempt.
523 recovery.failSnapshotRecovery();
524 }
525 };
526
527 const bootstrapFromSnapshot = async (): Promise<void> => {
528 await runSnapshotRecovery("bootstrap");
529 };
530 bootstrapFromSnapshotRef.current = bootstrapFromSnapshot;
623 };
531624
532 const fallbackToSnapshotRecovery = async (): Promise<void> => {
533 await runSnapshotRecovery("replay-failed");
534 };
535 const unsubDomainEvent = api.orchestration.onDomainEvent(
536 (event) => {
537 const action = recovery.classifyDomainEvent(event.sequence);
538 if (action === "apply") {
539 pendingDomainEvents.push(event);
540 schedulePendingDomainEventFlush();
541 return;
625 const unsubLifecycle = entry.client.server.subscribeLifecycle((event) => {
626 if (event.type === "welcome") {
627 bindEnvironmentId(event.payload.environment.environmentId);
542628 }
543 if (action === "recover") {
544 flushPendingDomainEvents();
545 void runReplayRecovery("sequence-gap");
629 });
630 const unsubConfig = entry.client.server.subscribeConfig((event) => {
631 if (event.type === "snapshot") {
632 bindEnvironmentId(event.config.environment.environmentId);
546633 }
547 },
548 {
549 onResubscribe: () => {
550 if (disposed) {
634 });
635 if (boundEnvironmentId === null) {
636 void entry.client.server
637 .getConfig()
638 .then((config) => {
639 if (!disposed) {
640 bindEnvironmentId(config.environment.environmentId);
641 }
642 })
643 .catch(() => undefined);
644 }
645 const unsubDomainEvent = entry.client.orchestration.onDomainEvent(
646 (event) => {
647 const action = recovery.classifyDomainEvent(event.sequence);
648 if (action === "apply") {
649 pendingDomainEvents.push(event);
650 schedulePendingDomainEventFlush();
551651 return;
552652 }
553 flushPendingDomainEvents();
554 void runReplayRecovery("resubscribe");
653 if (action === "recover") {
654 flushPendingDomainEvents();
655 void runReplayRecovery("sequence-gap");
656 }
555657 },
556 },
557 );
558 const unsubTerminalEvent = api.terminal.onEvent((event) => {
559 const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState());
560 if (thread && thread.archivedAt !== null) {
658 {
659 onResubscribe: () => {
660 if (disposed) {
661 return;
662 }
663 flushPendingDomainEvents();
664 void runReplayRecovery("resubscribe");
665 },
666 },
667 );
668 const unsubTerminalEvent = entry.client.terminal.onEvent((event) => {
669 if (boundEnvironmentId === null) {
670 return;
671 }
672
673 const threadRef = scopeThreadRef(boundEnvironmentId, ThreadId.makeUnsafe(event.threadId));
674 const thread = selectThreadByRef(useStore.getState(), threadRef);
675 if (!thread || thread.archivedAt !== null) {
676 return;
677 }
678 applyTerminalEvent(threadRef, event);
679 });
680
681 return {
682 key: entry.key,
683 bindEnvironmentId,
684 flushPendingDomainEvents,
685 schedulePendingDomainEventFlush,
686 runSnapshotRecovery,
687 cleanup: () => {
688 flushPendingDomainEventsScheduled = false;
689 pendingDomainEvents.length = 0;
690 unsubDomainEvent();
691 unsubTerminalEvent();
692 unsubLifecycle();
693 unsubConfig();
694 },
695 };
696 });
697
698 schedulePendingDomainEventFlushRef.current = () => {
699 for (const context of clientContexts) {
700 context.schedulePendingDomainEventFlush();
701 }
702 };
703
704 const primaryClientContext =
705 clientContexts.find((context) => context.key === primaryClientKey) ??
706 clientContexts[0] ??
707 null;
708 bootstrapFromSnapshotRef.current = async (environmentId: EnvironmentId) => {
709 if (!primaryClientContext) {
561710 return;
562711 }
563 applyTerminalEvent(event);
564 });
712 primaryClientContext.bindEnvironmentId(environmentId);
713 await primaryClientContext.runSnapshotRecovery("bootstrap", environmentId);
714 };
715
565716 return () => {
566717 disposed = true;
567718 disposedRef.current = true;
568719 needsProviderInvalidation = false;
569 flushPendingDomainEventsScheduled = false;
570 pendingDomainEvents.length = 0;
720 schedulePendingDomainEventFlushRef.current = () => undefined;
571721 queryInvalidationThrottler.cancel();
572 unsubDomainEvent();
573 unsubTerminalEvent();
722 for (const context of clientContexts) {
723 context.cleanup();
724 }
574725 };
575726 }, [
576727 applyOrchestrationEvents,
728 clientEntries,
577729 navigate,
578730 queryClient,
579731 removeTerminalState,
......@@ -581,6 +733,7 @@ function EventRouter() {
581733 applyTerminalEvent,
582734 clearThreadUi,
583735 setProjectExpanded,
736 setActiveEnvironmentId,
584737 syncProjects,
585738 syncServerReadModel,
586739 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 @@
1import { scopeProjectRef } from "@t3tools/client-runtime";
12import { Outlet, createFileRoute } from "@tanstack/react-router";
23import { useEffect } from "react";
34
......@@ -12,13 +13,13 @@ import { useServerKeybindings } from "~/rpc/serverState";
1213
1314function ChatRouteGlobalShortcuts() {
1415 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
15 const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size);
16 const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } =
16 const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size);
17 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
1718 useHandleNewThread();
1819 const keybindings = useServerKeybindings();
1920 const terminalOpen = useTerminalStateStore((state) =>
20 routeThreadId
21 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen
21 routeThreadRef
22 ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen
2223 : false,
2324 );
2425 const appSettings = useSettings();
......@@ -27,14 +28,18 @@ function ChatRouteGlobalShortcuts() {
2728 const onWindowKeyDown = (event: KeyboardEvent) => {
2829 if (event.defaultPrevented) return;
2930
30 if (event.key === "Escape" && selectedThreadIdsSize > 0) {
31 if (event.key === "Escape" && selectedThreadKeysSize > 0) {
3132 event.preventDefault();
3233 clearSelection();
3334 return;
3435 }
3536
36 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId;
37 if (!projectId) return;
37 const projectRef = activeThread
38 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
39 : activeDraftThread && routeThreadRef
40 ? scopeProjectRef(routeThreadRef.environmentId, activeDraftThread.projectId)
41 : defaultProjectRef;
42 if (!projectRef) return;
3843
3944 const command = resolveShortcutCommand(event, keybindings, {
4045 context: {
......@@ -46,7 +51,7 @@ function ChatRouteGlobalShortcuts() {
4651 if (command === "chat.newLocal") {
4752 event.preventDefault();
4853 event.stopPropagation();
49 void handleNewThread(projectId, {
54 void handleNewThread(projectRef, {
5055 envMode: resolveSidebarNewThreadEnvMode({
5156 defaultEnvMode: appSettings.defaultThreadEnvMode,
5257 }),
......@@ -57,7 +62,7 @@ function ChatRouteGlobalShortcuts() {
5762 if (command === "chat.new") {
5863 event.preventDefault();
5964 event.stopPropagation();
60 void handleNewThread(projectId, {
65 void handleNewThread(projectRef, {
6166 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
6267 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
6368 envMode:
......@@ -77,8 +82,9 @@ function ChatRouteGlobalShortcuts() {
7782 clearSelection,
7883 handleNewThread,
7984 keybindings,
80 defaultProjectId,
81 selectedThreadIdsSize,
85 defaultProjectRef,
86 routeThreadRef,
87 selectedThreadKeysSize,
8288 terminalOpen,
8389 appSettings.defaultThreadEnvMode,
8490 ]);
apps/web/src/rpc/serverState.test.ts+18
......@@ -1,5 +1,6 @@
11import {
22 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,
34 ProjectId,
45 ThreadId,
56 type ServerConfig,
......@@ -50,7 +51,21 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [
5051 },
5152];
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
5367const baseServerConfig: ServerConfig = {
68 environment: baseEnvironment,
5469 cwd: "/tmp/workspace",
5570 keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
5671 keybindings: [],
......@@ -193,6 +208,7 @@ describe("serverState", () => {
193208 sequence: 1,
194209 type: "welcome",
195210 payload: {
211 environment: baseEnvironment,
196212 cwd: "/tmp/workspace",
197213 projectName: "t3-code",
198214 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
......@@ -201,6 +217,7 @@ describe("serverState", () => {
201217 });
202218
203219 expect(listener).toHaveBeenCalledWith({
220 environment: baseEnvironment,
204221 cwd: "/tmp/workspace",
205222 projectName: "t3-code",
206223 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
......@@ -210,6 +227,7 @@ describe("serverState", () => {
210227 const lateListener = vi.fn();
211228 const unsubscribeLate = onWelcome(lateListener);
212229 expect(lateListener).toHaveBeenCalledWith({
230 environment: baseEnvironment,
213231 cwd: "/tmp/workspace",
214232 projectName: "t3-code",
215233 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
apps/web/src/store.test.ts+166-105
......@@ -1,6 +1,7 @@
11import {
22 CheckpointRef,
33 DEFAULT_MODEL_BY_PROVIDER,
4 EnvironmentId,
45 EventId,
56 MessageId,
67 ProjectId,
......@@ -14,16 +15,49 @@ import { describe, expect, it } from "vitest";
1415import {
1516 applyOrchestrationEvent,
1617 applyOrchestrationEvents,
17 selectProjects,
18 selectThreads,
18 selectEnvironmentState,
19 selectProjectsAcrossEnvironments,
20 selectThreadsAcrossEnvironments,
1921 syncServerReadModel,
2022 type AppState,
23 type EnvironmentState,
2124} from "./store";
2225import { 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
2457function makeThread(overrides: Partial<Thread> = {}): Thread {
2558 return {
2659 id: ThreadId.makeUnsafe("thread-1"),
60 environmentId: localEnvironmentId,
2761 codexThreadId: null,
2862 projectId: ProjectId.makeUnsafe("project-1"),
2963 title: "Thread",
......@@ -52,6 +86,7 @@ function makeState(thread: Thread): AppState {
5286 const projectId = ProjectId.makeUnsafe("project-1");
5387 const project = {
5488 id: projectId,
89 environmentId: localEnvironmentId,
5590 name: "Project",
5691 cwd: "/tmp/project",
5792 defaultModelSelection: {
......@@ -62,10 +97,10 @@ function makeState(thread: Thread): AppState {
6297 updatedAt: "2026-02-13T00:00:00.000Z",
6398 scripts: [],
6499 };
65 const threadIdsByProjectId: AppState["threadIdsByProjectId"] = {
100 const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = {
66101 [thread.projectId]: [thread.id],
67102 };
68 return {
103 const environmentState = {
69104 projectIds: [projectId],
70105 projectById: {
71106 [projectId]: project,
......@@ -75,6 +110,7 @@ function makeState(thread: Thread): AppState {
75110 threadShellById: {
76111 [thread.id]: {
77112 id: thread.id,
113 environmentId: thread.environmentId,
78114 codexThreadId: thread.codexThreadId,
79115 projectId: thread.projectId,
80116 title: thread.title,
......@@ -106,7 +142,7 @@ function makeState(thread: Thread): AppState {
106142 messageByThreadId: {
107143 [thread.id]: Object.fromEntries(
108144 thread.messages.map((message) => [message.id, message] as const),
109 ) as AppState["messageByThreadId"][ThreadId],
145 ) as EnvironmentState["messageByThreadId"][ThreadId],
110146 },
111147 activityIdsByThreadId: {
112148 [thread.id]: thread.activities.map((activity) => activity.id),
......@@ -114,7 +150,7 @@ function makeState(thread: Thread): AppState {
114150 activityByThreadId: {
115151 [thread.id]: Object.fromEntries(
116152 thread.activities.map((activity) => [activity.id, activity] as const),
117 ) as AppState["activityByThreadId"][ThreadId],
153 ) as EnvironmentState["activityByThreadId"][ThreadId],
118154 },
119155 proposedPlanIdsByThreadId: {
120156 [thread.id]: thread.proposedPlans.map((plan) => plan.id),
......@@ -122,7 +158,7 @@ function makeState(thread: Thread): AppState {
122158 proposedPlanByThreadId: {
123159 [thread.id]: Object.fromEntries(
124160 thread.proposedPlans.map((plan) => [plan.id, plan] as const),
125 ) as AppState["proposedPlanByThreadId"][ThreadId],
161 ) as EnvironmentState["proposedPlanByThreadId"][ThreadId],
126162 },
127163 turnDiffIdsByThreadId: {
128164 [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId),
......@@ -130,15 +166,16 @@ function makeState(thread: Thread): AppState {
130166 turnDiffSummaryByThreadId: {
131167 [thread.id]: Object.fromEntries(
132168 thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const),
133 ) as AppState["turnDiffSummaryByThreadId"][ThreadId],
169 ) as EnvironmentState["turnDiffSummaryByThreadId"][ThreadId],
134170 },
135171 sidebarThreadSummaryById: {},
136172 bootstrapComplete: true,
137173 };
174 return withActiveEnvironmentState(environmentState);
138175}
139176
140function makeEmptyState(overrides: Partial<AppState> = {}): AppState {
141 return {
177function makeEmptyState(overrides: Partial<AppState & EnvironmentState> = {}): AppState {
178 const environmentState: EnvironmentState = {
142179 projectIds: [],
143180 projectById: {},
144181 threadIds: [],
......@@ -156,16 +193,20 @@ function makeEmptyState(overrides: Partial<AppState> = {}): AppState {
156193 turnDiffSummaryByThreadId: {},
157194 sidebarThreadSummaryById: {},
158195 bootstrapComplete: true,
159 ...overrides,
160196 };
197 return withActiveEnvironmentState(environmentState, overrides);
198}
199
200function localEnvironmentStateOf(state: AppState): EnvironmentState {
201 return selectEnvironmentState(state, localEnvironmentId);
161202}
162203
163204function projectsOf(state: AppState) {
164 return selectProjects(state);
205 return selectProjectsAcrossEnvironments(state);
165206}
166207
167208function threadsOf(state: AppState) {
168 return selectThreads(state);
209 return selectThreadsAcrossEnvironments(state);
169210}
170211
171212function makeEvent<T extends OrchestrationEvent["type"]>(
......@@ -266,14 +307,20 @@ function makeReadModelProject(
266307
267308describe("store read model sync", () => {
268309 it("marks bootstrap complete after snapshot sync", () => {
269 const initialState: AppState = {
270 ...makeState(makeThread()),
271 bootstrapComplete: false,
272 };
310 const initialState = withActiveEnvironmentState(
311 localEnvironmentStateOf(makeState(makeThread())),
312 {
313 bootstrapComplete: false,
314 },
315 );
273316
274 const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({})));
317 const next = syncServerReadModel(
318 initialState,
319 makeReadModel(makeReadModelThread({})),
320 localEnvironmentId,
321 );
275322
276 expect(next.bootstrapComplete).toBe(true);
323 expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true);
277324 });
278325
279326 it("preserves claude model slugs without an active session", () => {
......@@ -287,7 +334,7 @@ describe("store read model sync", () => {
287334 }),
288335 );
289336
290 const next = syncServerReadModel(initialState, readModel);
337 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
291338
292339 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6");
293340 });
......@@ -312,7 +359,7 @@ describe("store read model sync", () => {
312359 }),
313360 );
314361
315 const next = syncServerReadModel(initialState, readModel);
362 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
316363
317364 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6");
318365 });
......@@ -325,7 +372,7 @@ describe("store read model sync", () => {
325372 }),
326373 );
327374
328 const next = syncServerReadModel(initialState, readModel);
375 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
329376
330377 expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z");
331378 expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z");
......@@ -341,6 +388,7 @@ describe("store read model sync", () => {
341388 archivedAt,
342389 }),
343390 ),
391 localEnvironmentId,
344392 );
345393
346394 expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt);
......@@ -355,6 +403,7 @@ describe("store read model sync", () => {
355403 projectById: {
356404 [project2]: {
357405 id: project2,
406 environmentId: localEnvironmentId,
358407 name: "Project 2",
359408 cwd: "/tmp/project-2",
360409 defaultModelSelection: {
......@@ -367,6 +416,7 @@ describe("store read model sync", () => {
367416 },
368417 [project1]: {
369418 id: project1,
419 environmentId: localEnvironmentId,
370420 name: "Project 1",
371421 cwd: "/tmp/project-1",
372422 defaultModelSelection: {
......@@ -402,7 +452,7 @@ describe("store read model sync", () => {
402452 threads: [],
403453 };
404454
405 const next = syncServerReadModel(initialState, readModel);
455 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
406456
407457 expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]);
408458 });
......@@ -410,10 +460,9 @@ describe("store read model sync", () => {
410460
411461describe("incremental orchestration updates", () => {
412462 it("does not mark bootstrap complete for incremental events", () => {
413 const state: AppState = {
414 ...makeState(makeThread()),
463 const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), {
415464 bootstrapComplete: false,
416 };
465 });
417466
418467 const next = applyOrchestrationEvent(
419468 state,
......@@ -422,30 +471,10 @@ describe("incremental orchestration updates", () => {
422471 title: "Updated title",
423472 updatedAt: "2026-02-27T00:00:01.000Z",
424473 }),
474 localEnvironmentId,
425475 );
426476
427 expect(next.bootstrapComplete).toBe(false);
428 });
429
430 it("updates the existing project title when project.meta-updated arrives", () => {
431 const projectId = ProjectId.makeUnsafe("project-1");
432 const state = makeState(
433 makeThread({
434 projectId,
435 }),
436 );
437
438 const next = applyOrchestrationEvent(
439 state,
440 makeEvent("project.meta-updated", {
441 projectId,
442 title: "Renamed Project",
443 updatedAt: "2026-02-27T00:00:01.000Z",
444 }),
445 );
446
447 expect(next.projectById[projectId]?.name).toBe("Renamed Project");
448 expect(next.projectById[projectId]?.updatedAt).toBe("2026-02-27T00:00:01.000Z");
477 expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(false);
449478 });
450479
451480 it("preserves state identity for no-op project and thread deletes", () => {
......@@ -458,6 +487,7 @@ describe("incremental orchestration updates", () => {
458487 projectId: ProjectId.makeUnsafe("project-missing"),
459488 deletedAt: "2026-02-27T00:00:01.000Z",
460489 }),
490 localEnvironmentId,
461491 );
462492 const nextAfterThreadDelete = applyOrchestrationEvent(
463493 state,
......@@ -465,6 +495,7 @@ describe("incremental orchestration updates", () => {
465495 threadId: ThreadId.makeUnsafe("thread-missing"),
466496 deletedAt: "2026-02-27T00:00:01.000Z",
467497 }),
498 localEnvironmentId,
468499 );
469500
470501 expect(nextAfterProjectDelete).toBe(state);
......@@ -479,6 +510,7 @@ describe("incremental orchestration updates", () => {
479510 projectById: {
480511 [originalProjectId]: {
481512 id: originalProjectId,
513 environmentId: localEnvironmentId,
482514 name: "Project",
483515 cwd: "/tmp/project",
484516 defaultModelSelection: {
......@@ -506,15 +538,18 @@ describe("incremental orchestration updates", () => {
506538 createdAt: "2026-02-27T00:00:01.000Z",
507539 updatedAt: "2026-02-27T00:00:01.000Z",
508540 }),
541 localEnvironmentId,
509542 );
510543
511544 expect(projectsOf(next)).toHaveLength(1);
512545 expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId);
513546 expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project");
514547 expect(projectsOf(next)[0]?.name).toBe("Project Recreated");
515 expect(next.projectIds).toEqual([recreatedProjectId]);
516 expect(next.projectById[originalProjectId]).toBeUndefined();
517 expect(next.projectById[recreatedProjectId]?.id).toBe(recreatedProjectId);
548 expect(localEnvironmentStateOf(next).projectIds).toEqual([recreatedProjectId]);
549 expect(localEnvironmentStateOf(next).projectById[originalProjectId]).toBeUndefined();
550 expect(localEnvironmentStateOf(next).projectById[recreatedProjectId]?.id).toBe(
551 recreatedProjectId,
552 );
518553 });
519554
520555 it("removes stale project index entries when thread.created recreates a thread under a new project", () => {
......@@ -525,12 +560,12 @@ describe("incremental orchestration updates", () => {
525560 id: threadId,
526561 projectId: originalProjectId,
527562 });
528 const state: AppState = {
529 ...makeState(thread),
563 const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(thread)), {
530564 projectIds: [originalProjectId, recreatedProjectId],
531565 projectById: {
532566 [originalProjectId]: {
533567 id: originalProjectId,
568 environmentId: localEnvironmentId,
534569 name: "Project 1",
535570 cwd: "/tmp/project-1",
536571 defaultModelSelection: {
......@@ -543,6 +578,7 @@ describe("incremental orchestration updates", () => {
543578 },
544579 [recreatedProjectId]: {
545580 id: recreatedProjectId,
581 environmentId: localEnvironmentId,
546582 name: "Project 2",
547583 cwd: "/tmp/project-2",
548584 defaultModelSelection: {
......@@ -554,7 +590,7 @@ describe("incremental orchestration updates", () => {
554590 scripts: [],
555591 },
556592 },
557 };
593 });
558594
559595 const next = applyOrchestrationEvent(
560596 state,
......@@ -573,12 +609,15 @@ describe("incremental orchestration updates", () => {
573609 createdAt: "2026-02-27T00:00:01.000Z",
574610 updatedAt: "2026-02-27T00:00:01.000Z",
575611 }),
612 localEnvironmentId,
576613 );
577614
578615 expect(threadsOf(next)).toHaveLength(1);
579616 expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId);
580 expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined();
581 expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]);
617 expect(localEnvironmentStateOf(next).threadIdsByProjectId[originalProjectId]).toBeUndefined();
618 expect(localEnvironmentStateOf(next).threadIdsByProjectId[recreatedProjectId]).toEqual([
619 threadId,
620 ]);
582621 });
583622
584623 it("updates only the affected thread for message events", () => {
......@@ -597,13 +636,15 @@ describe("incremental orchestration updates", () => {
597636 ],
598637 });
599638 const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") });
600 const state: AppState = {
601 ...makeState(thread1),
639 const baseState = makeState(thread1);
640 const baseEnvironmentState = localEnvironmentStateOf(baseState);
641 const state = withActiveEnvironmentState(baseEnvironmentState, {
602642 threadIds: [thread1.id, thread2.id],
603643 threadShellById: {
604 ...makeState(thread1).threadShellById,
644 ...baseEnvironmentState.threadShellById,
605645 [thread2.id]: {
606646 id: thread2.id,
647 environmentId: thread2.environmentId,
607648 codexThreadId: thread2.codexThreadId,
608649 projectId: thread2.projectId,
609650 title: thread2.title,
......@@ -619,54 +660,54 @@ describe("incremental orchestration updates", () => {
619660 },
620661 },
621662 threadSessionById: {
622 ...makeState(thread1).threadSessionById,
663 ...baseEnvironmentState.threadSessionById,
623664 [thread2.id]: thread2.session,
624665 },
625666 threadTurnStateById: {
626 ...makeState(thread1).threadTurnStateById,
667 ...baseEnvironmentState.threadTurnStateById,
627668 [thread2.id]: {
628669 latestTurn: thread2.latestTurn,
629670 },
630671 },
631672 messageIdsByThreadId: {
632 ...makeState(thread1).messageIdsByThreadId,
673 ...baseEnvironmentState.messageIdsByThreadId,
633674 [thread2.id]: [],
634675 },
635676 messageByThreadId: {
636 ...makeState(thread1).messageByThreadId,
677 ...baseEnvironmentState.messageByThreadId,
637678 [thread2.id]: {},
638679 },
639680 activityIdsByThreadId: {
640 ...makeState(thread1).activityIdsByThreadId,
681 ...baseEnvironmentState.activityIdsByThreadId,
641682 [thread2.id]: [],
642683 },
643684 activityByThreadId: {
644 ...makeState(thread1).activityByThreadId,
685 ...baseEnvironmentState.activityByThreadId,
645686 [thread2.id]: {},
646687 },
647688 proposedPlanIdsByThreadId: {
648 ...makeState(thread1).proposedPlanIdsByThreadId,
689 ...baseEnvironmentState.proposedPlanIdsByThreadId,
649690 [thread2.id]: [],
650691 },
651692 proposedPlanByThreadId: {
652 ...makeState(thread1).proposedPlanByThreadId,
693 ...baseEnvironmentState.proposedPlanByThreadId,
653694 [thread2.id]: {},
654695 },
655696 turnDiffIdsByThreadId: {
656 ...makeState(thread1).turnDiffIdsByThreadId,
697 ...baseEnvironmentState.turnDiffIdsByThreadId,
657698 [thread2.id]: [],
658699 },
659700 turnDiffSummaryByThreadId: {
660 ...makeState(thread1).turnDiffSummaryByThreadId,
701 ...baseEnvironmentState.turnDiffSummaryByThreadId,
661702 [thread2.id]: {},
662703 },
663704 sidebarThreadSummaryById: {
664 ...makeState(thread1).sidebarThreadSummaryById,
705 ...baseEnvironmentState.sidebarThreadSummaryById,
665706 },
666707 threadIdsByProjectId: {
667708 [thread1.projectId]: [thread1.id, thread2.id],
668709 },
669 };
710 });
670711
671712 const next = applyOrchestrationEvent(
672713 state,
......@@ -680,14 +721,25 @@ describe("incremental orchestration updates", () => {
680721 createdAt: "2026-02-27T00:00:01.000Z",
681722 updatedAt: "2026-02-27T00:00:01.000Z",
682723 }),
724 localEnvironmentId,
683725 );
684726
685727 expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world");
686728 expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running");
687 expect(next.threadShellById[thread2.id]).toBe(state.threadShellById[thread2.id]);
688 expect(next.threadSessionById[thread2.id]).toBe(state.threadSessionById[thread2.id]);
689 expect(next.messageIdsByThreadId[thread2.id]).toBe(state.messageIdsByThreadId[thread2.id]);
690 expect(next.messageByThreadId[thread2.id]).toBe(state.messageByThreadId[thread2.id]);
729 const nextEnvironmentState = next.environmentStateById[localEnvironmentId];
730 const previousEnvironmentState = state.environmentStateById[localEnvironmentId];
731 expect(nextEnvironmentState?.threadShellById[thread2.id]).toBe(
732 previousEnvironmentState?.threadShellById[thread2.id],
733 );
734 expect(nextEnvironmentState?.threadSessionById[thread2.id]).toBe(
735 previousEnvironmentState?.threadSessionById[thread2.id],
736 );
737 expect(nextEnvironmentState?.messageIdsByThreadId[thread2.id]).toBe(
738 previousEnvironmentState?.messageIdsByThreadId[thread2.id],
739 );
740 expect(nextEnvironmentState?.messageByThreadId[thread2.id]).toBe(
741 previousEnvironmentState?.messageByThreadId[thread2.id],
742 );
691743 });
692744
693745 it("applies replay batches in sequence and updates session state", () => {
......@@ -703,38 +755,42 @@ describe("incremental orchestration updates", () => {
703755 });
704756 const state = makeState(thread);
705757
706 const next = applyOrchestrationEvents(state, [
707 makeEvent(
708 "thread.session-set",
709 {
710 threadId: thread.id,
711 session: {
758 const next = applyOrchestrationEvents(
759 state,
760 [
761 makeEvent(
762 "thread.session-set",
763 {
712764 threadId: thread.id,
713 status: "running",
714 providerName: "codex",
715 runtimeMode: "full-access",
716 activeTurnId: TurnId.makeUnsafe("turn-1"),
717 lastError: null,
718 updatedAt: "2026-02-27T00:00:02.000Z",
765 session: {
766 threadId: thread.id,
767 status: "running",
768 providerName: "codex",
769 runtimeMode: "full-access",
770 activeTurnId: TurnId.makeUnsafe("turn-1"),
771 lastError: null,
772 updatedAt: "2026-02-27T00:00:02.000Z",
773 },
719774 },
720 },
721 { sequence: 2 },
722 ),
723 makeEvent(
724 "thread.message-sent",
725 {
726 threadId: thread.id,
727 messageId: MessageId.makeUnsafe("assistant-1"),
728 role: "assistant",
729 text: "done",
730 turnId: TurnId.makeUnsafe("turn-1"),
731 streaming: false,
732 createdAt: "2026-02-27T00:00:03.000Z",
733 updatedAt: "2026-02-27T00:00:03.000Z",
734 },
735 { sequence: 3 },
736 ),
737 ]);
775 { sequence: 2 },
776 ),
777 makeEvent(
778 "thread.message-sent",
779 {
780 threadId: thread.id,
781 messageId: MessageId.makeUnsafe("assistant-1"),
782 role: "assistant",
783 text: "done",
784 turnId: TurnId.makeUnsafe("turn-1"),
785 streaming: false,
786 createdAt: "2026-02-27T00:00:03.000Z",
787 updatedAt: "2026-02-27T00:00:03.000Z",
788 },
789 { sequence: 3 },
790 ),
791 ],
792 localEnvironmentId,
793 );
738794
739795 expect(threadsOf(next)[0]?.session?.status).toBe("running");
740796 expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed");
......@@ -767,6 +823,7 @@ describe("incremental orchestration updates", () => {
767823 assistantMessageId: MessageId.makeUnsafe("assistant-1"),
768824 completedAt: "2026-02-27T00:00:04.000Z",
769825 }),
826 localEnvironmentId,
770827 );
771828
772829 expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1);
......@@ -811,6 +868,7 @@ describe("incremental orchestration updates", () => {
811868 createdAt: "2026-02-27T00:00:03.000Z",
812869 updatedAt: "2026-02-27T00:00:03.000Z",
813870 }),
871 localEnvironmentId,
814872 );
815873
816874 expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe(
......@@ -920,6 +978,7 @@ describe("incremental orchestration updates", () => {
920978 threadId: ThreadId.makeUnsafe("thread-1"),
921979 turnCount: 1,
922980 }),
981 localEnvironmentId,
923982 );
924983
925984 expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([
......@@ -978,6 +1037,7 @@ describe("incremental orchestration updates", () => {
9781037 threadId: thread.id,
9791038 turnCount: 1,
9801039 }),
1040 localEnvironmentId,
9811041 );
9821042
9831043 expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined();
......@@ -996,6 +1056,7 @@ describe("incremental orchestration updates", () => {
9961056 updatedAt: "2026-02-27T00:00:04.000Z",
9971057 },
9981058 }),
1059 localEnvironmentId,
9991060 );
10001061
10011062 expect(threadsOf(next)[0]?.latestTurn).toMatchObject({
apps/web/src/store.ts+336-92
......@@ -1,4 +1,5 @@
11import {
2 type EnvironmentId,
23 type MessageId,
34 type OrchestrationCheckpointSummary,
45 type OrchestrationEvent,
......@@ -11,6 +12,8 @@ import {
1112 type OrchestrationThreadActivity,
1213 type ProjectId,
1314 type ProviderKind,
15 type ScopedProjectRef,
16 type ScopedThreadRef,
1417 ThreadId,
1518 type TurnId,
1619} from "@t3tools/contracts";
......@@ -35,7 +38,7 @@ import {
3538} from "./types";
3639import { sanitizeThreadErrorMessage } from "./rpc/transportError";
3740
38export interface AppState {
41export interface EnvironmentState {
3942 projectIds: ProjectId[];
4043 projectById: Record<ProjectId, Project>;
4144 threadIds: ThreadId[];
......@@ -55,7 +58,12 @@ export interface AppState {
5558 bootstrapComplete: boolean;
5659}
5760
58const initialState: AppState = {
61export interface AppState {
62 activeEnvironmentId: EnvironmentId | null;
63 environmentStateById: Record<string, EnvironmentState>;
64}
65
66const initialEnvironmentState: EnvironmentState = {
5967 projectIds: [],
6068 projectById: {},
6169 threadIds: [],
......@@ -75,6 +83,11 @@ const initialState: AppState = {
7583 bootstrapComplete: false,
7684};
7785
86const initialState: AppState = {
87 activeEnvironmentId: null,
88 environmentStateById: {},
89};
90
7891const MAX_THREAD_MESSAGES = 2_000;
7992const MAX_THREAD_CHECKPOINTS = 500;
8093const MAX_THREAD_PROPOSED_PLANS = 200;
......@@ -169,11 +182,16 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif
169182 };
170183}
171184
172function mapProject(project: OrchestrationReadModel["projects"][number]): Project {
185function mapProject(
186 project: OrchestrationReadModel["projects"][number],
187 environmentId: EnvironmentId,
188): Project {
173189 return {
174190 id: project.id,
191 environmentId,
175192 name: project.title,
176193 cwd: project.workspaceRoot,
194 repositoryIdentity: project.repositoryIdentity ?? null,
177195 defaultModelSelection: project.defaultModelSelection
178196 ? normalizeModelSelection(project.defaultModelSelection)
179197 : null,
......@@ -183,9 +201,10 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec
183201 };
184202}
185203
186function mapThread(thread: OrchestrationThread): Thread {
204function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread {
187205 return {
188206 id: thread.id,
207 environmentId,
189208 codexThreadId: null,
190209 projectId: thread.projectId,
191210 title: thread.title,
......@@ -211,6 +230,7 @@ function mapThread(thread: OrchestrationThread): Thread {
211230function toThreadShell(thread: Thread): ThreadShell {
212231 return {
213232 id: thread.id,
233 environmentId: thread.environmentId,
214234 codexThreadId: thread.codexThreadId,
215235 projectId: thread.projectId,
216236 title: thread.title,
......@@ -251,6 +271,7 @@ function getLatestUserMessageAt(messages: ReadonlyArray<ChatMessage>): string |
251271function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary {
252272 return {
253273 id: thread.id,
274 environmentId: thread.environmentId,
254275 projectId: thread.projectId,
255276 title: thread.title,
256277 interactionMode: thread.interactionMode,
......@@ -298,6 +319,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b
298319 return (
299320 left !== undefined &&
300321 left.id === right.id &&
322 left.environmentId === right.environmentId &&
301323 left.codexThreadId === right.codexThreadId &&
302324 left.projectId === right.projectId &&
303325 left.title === right.title &&
......@@ -377,7 +399,7 @@ function buildTurnDiffSlice(thread: Thread): {
377399 };
378400}
379401
380function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] {
402function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] {
381403 const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS;
382404 const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP;
383405 if (ids.length === 0) {
......@@ -390,7 +412,7 @@ function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[
390412}
391413
392414function selectThreadActivities(
393 state: AppState,
415 state: EnvironmentState,
394416 threadId: ThreadId,
395417): OrchestrationThreadActivity[] {
396418 const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS;
......@@ -404,7 +426,7 @@ function selectThreadActivities(
404426 });
405427}
406428
407function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] {
429function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] {
408430 const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS;
409431 const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP;
410432 if (ids.length === 0) {
......@@ -416,7 +438,10 @@ function selectThreadProposedPlans(state: AppState, threadId: ThreadId): Propose
416438 });
417439}
418440
419function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] {
441function selectThreadTurnDiffSummaries(
442 state: EnvironmentState,
443 threadId: ThreadId,
444): TurnDiffSummary[] {
420445 const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS;
421446 const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP;
422447 if (ids.length === 0) {
......@@ -428,7 +453,7 @@ function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): Tur
428453 });
429454}
430455
431function getThread(state: AppState, threadId: ThreadId): Thread | undefined {
456function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined {
432457 const shell = state.threadShellById[threadId];
433458 if (!shell) {
434459 return undefined;
......@@ -446,21 +471,25 @@ function getThread(state: AppState, threadId: ThreadId): Thread | undefined {
446471 };
447472}
448473
449function getProjects(state: AppState): Project[] {
474function getProjects(state: EnvironmentState): Project[] {
450475 return state.projectIds.flatMap((projectId) => {
451476 const project = state.projectById[projectId];
452477 return project ? [project] : [];
453478 });
454479}
455480
456function getThreads(state: AppState): Thread[] {
481function getThreads(state: EnvironmentState): Thread[] {
457482 return state.threadIds.flatMap((threadId) => {
458483 const thread = getThread(state, threadId);
459484 return thread ? [thread] : [];
460485 });
461486}
462487
463function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState {
488function writeThreadState(
489 state: EnvironmentState,
490 nextThread: Thread,
491 previousThread?: Thread,
492): EnvironmentState {
464493 const nextShell = toThreadShell(nextThread);
465494 const nextTurnState = toThreadTurnState(nextThread);
466495 const previousShell = state.threadShellById[nextThread.id];
......@@ -613,7 +642,7 @@ function writeThreadState(state: AppState, nextThread: Thread, previousThread?:
613642 return nextState;
614643}
615644
616function removeThreadState(state: AppState, threadId: ThreadId): AppState {
645function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState {
617646 const shell = state.threadShellById[threadId];
618647 if (!shell) {
619648 return state;
......@@ -887,10 +916,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string {
887916}
888917
889918function updateThreadState(
890 state: AppState,
919 state: EnvironmentState,
891920 threadId: ThreadId,
892921 updater: (thread: Thread) => Thread,
893): AppState {
922): EnvironmentState {
894923 const currentThread = getThread(state, threadId);
895924 if (!currentThread) {
896925 return state;
......@@ -904,7 +933,7 @@ function updateThreadState(
904933
905934function buildProjectState(
906935 projects: ReadonlyArray<Project>,
907): Pick<AppState, "projectIds" | "projectById"> {
936): Pick<EnvironmentState, "projectIds" | "projectById"> {
908937 return {
909938 projectIds: projects.map((project) => project.id),
910939 projectById: Object.fromEntries(
......@@ -916,7 +945,7 @@ function buildProjectState(
916945function buildThreadState(
917946 threads: ReadonlyArray<Thread>,
918947): Pick<
919 AppState,
948 EnvironmentState,
920949 | "threadIds"
921950 | "threadIdsByProjectId"
922951 | "threadShellById"
......@@ -989,11 +1018,48 @@ function buildThreadState(
9891018 };
9901019}
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 {
9931057 const projects = readModel.projects
9941058 .filter((project) => project.deletedAt === null)
995 .map(mapProject);
996 const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread);
1059 .map((project) => mapProject(project, environmentId));
1060 const threads = readModel.threads
1061 .filter((thread) => thread.deletedAt === null)
1062 .map((thread) => mapThread(thread, environmentId));
9971063 return {
9981064 ...state,
9991065 ...buildProjectState(projects),
......@@ -1002,19 +1068,43 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
10021068 };
10031069}
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 {
10061092 switch (event.type) {
10071093 case "project.created": {
1008 const nextProject = mapProject({
1009 id: event.payload.projectId,
1010 title: event.payload.title,
1011 workspaceRoot: event.payload.workspaceRoot,
1012 defaultModelSelection: event.payload.defaultModelSelection,
1013 scripts: event.payload.scripts,
1014 createdAt: event.payload.createdAt,
1015 updatedAt: event.payload.updatedAt,
1016 deletedAt: null,
1017 });
1094 const nextProject = mapProject(
1095 {
1096 id: event.payload.projectId,
1097 title: event.payload.title,
1098 workspaceRoot: event.payload.workspaceRoot,
1099 repositoryIdentity: event.payload.repositoryIdentity ?? null,
1100 defaultModelSelection: event.payload.defaultModelSelection,
1101 scripts: event.payload.scripts,
1102 createdAt: event.payload.createdAt,
1103 updatedAt: event.payload.updatedAt,
1104 deletedAt: null,
1105 },
1106 environmentId,
1107 );
10181108 const existingProjectId =
10191109 state.projectIds.find(
10201110 (projectId) =>
......@@ -1060,6 +1150,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
10601150 ...project,
10611151 ...(event.payload.title !== undefined ? { name: event.payload.title } : {}),
10621152 ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}),
1153 ...(event.payload.repositoryIdentity !== undefined
1154 ? { repositoryIdentity: event.payload.repositoryIdentity ?? null }
1155 : {}),
10631156 ...(event.payload.defaultModelSelection !== undefined
10641157 ? {
10651158 defaultModelSelection: event.payload.defaultModelSelection
......@@ -1095,26 +1188,29 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
10951188
10961189 case "thread.created": {
10971190 const previousThread = getThread(state, event.payload.threadId);
1098 const nextThread = mapThread({
1099 id: event.payload.threadId,
1100 projectId: event.payload.projectId,
1101 title: event.payload.title,
1102 modelSelection: event.payload.modelSelection,
1103 runtimeMode: event.payload.runtimeMode,
1104 interactionMode: event.payload.interactionMode,
1105 branch: event.payload.branch,
1106 worktreePath: event.payload.worktreePath,
1107 latestTurn: null,
1108 createdAt: event.payload.createdAt,
1109 updatedAt: event.payload.updatedAt,
1110 archivedAt: null,
1111 deletedAt: null,
1112 messages: [],
1113 proposedPlans: [],
1114 activities: [],
1115 checkpoints: [],
1116 session: null,
1117 });
1191 const nextThread = mapThread(
1192 {
1193 id: event.payload.threadId,
1194 projectId: event.payload.projectId,
1195 title: event.payload.title,
1196 modelSelection: event.payload.modelSelection,
1197 runtimeMode: event.payload.runtimeMode,
1198 interactionMode: event.payload.interactionMode,
1199 branch: event.payload.branch,
1200 worktreePath: event.payload.worktreePath,
1201 latestTurn: null,
1202 createdAt: event.payload.createdAt,
1203 updatedAt: event.payload.updatedAt,
1204 archivedAt: null,
1205 deletedAt: null,
1206 messages: [],
1207 proposedPlans: [],
1208 activities: [],
1209 checkpoints: [],
1210 session: null,
1211 },
1212 environmentId,
1213 );
11181214 return writeThreadState(state, nextThread, previousThread);
11191215 }
11201216
......@@ -1481,70 +1577,218 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
14811577export function applyOrchestrationEvents(
14821578 state: AppState,
14831579 events: ReadonlyArray<OrchestrationEvent>,
1580 environmentId: EnvironmentId,
14841581): AppState {
14851582 if (events.length === 0) {
14861583 return state;
14871584 }
1488 return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state);
1489}
1490
1491export const selectProjects = (state: AppState): Project[] => getProjects(state);
1492export const selectThreads = (state: AppState): Thread[] => getThreads(state);
1493export const selectProjectById =
1494 (projectId: Project["id"] | null | undefined) =>
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;
1585 const currentEnvironmentState = getStoredEnvironmentState(state, environmentId);
1586 const nextEnvironmentState = events.reduce(
1587 (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId),
1588 currentEnvironmentState,
1589 );
1590 return commitEnvironmentState(state, environmentId, nextEnvironmentState);
1591}
15091592
1510export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
1511 return updateThreadState(state, threadId, (thread) => {
1512 if (thread.error === error) return thread;
1513 return { ...thread, error };
1593function getEnvironmentEntries(
1594 state: AppState,
1595): ReadonlyArray<readonly [EnvironmentId, EnvironmentState]> {
1596 return Object.entries(state.environmentStateById) as unknown as ReadonlyArray<
1597 readonly [EnvironmentId, EnvironmentState]
1598 >;
1599}
1600
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] : [];
15141656 });
15151657}
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
15171743export function setThreadBranch(
15181744 state: AppState,
15191745 threadId: ThreadId,
15201746 branch: string | null,
15211747 worktreePath: string | null,
15221748): AppState {
1523 return updateThreadState(state, threadId, (thread) => {
1524 if (thread.branch === branch && thread.worktreePath === worktreePath) return thread;
1525 const cwdChanged = thread.worktreePath !== worktreePath;
1526 return {
1527 ...thread,
1528 branch,
1529 worktreePath,
1530 ...(cwdChanged ? { session: null } : {}),
1531 };
1532 });
1749 if (state.activeEnvironmentId === null) {
1750 return state;
1751 }
1752
1753 const nextEnvironmentState = updateThreadState(
1754 getStoredEnvironmentState(state, state.activeEnvironmentId),
1755 threadId,
1756 (thread) => {
1757 if (thread.branch === branch && thread.worktreePath === worktreePath) return thread;
1758 const cwdChanged = thread.worktreePath !== worktreePath;
1759 return {
1760 ...thread,
1761 branch,
1762 worktreePath,
1763 ...(cwdChanged ? { session: null } : {}),
1764 };
1765 },
1766 );
1767 return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState);
15331768}
15341769
15351770interface AppStore extends AppState {
1536 syncServerReadModel: (readModel: OrchestrationReadModel) => void;
1537 applyOrchestrationEvent: (event: OrchestrationEvent) => void;
1538 applyOrchestrationEvents: (events: ReadonlyArray<OrchestrationEvent>) => void;
1771 setActiveEnvironmentId: (environmentId: EnvironmentId) => void;
1772 syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void;
1773 applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void;
1774 applyOrchestrationEvents: (
1775 events: ReadonlyArray<OrchestrationEvent>,
1776 environmentId: EnvironmentId,
1777 ) => void;
15391778 setError: (threadId: ThreadId, error: string | null) => void;
15401779 setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void;
15411780}
15421781
15431782export const useStore = create<AppStore>((set) => ({
15441783 ...initialState,
1545 syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)),
1546 applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)),
1547 applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)),
1784 setActiveEnvironmentId: (environmentId) =>
1785 set((state) => setActiveEnvironmentId(state, environmentId)),
1786 syncServerReadModel: (readModel, environmentId) =>
1787 set((state) => syncServerReadModel(state, readModel, environmentId)),
1788 applyOrchestrationEvent: (event, environmentId) =>
1789 set((state) => applyOrchestrationEvent(state, event, environmentId)),
1790 applyOrchestrationEvents: (events, environmentId) =>
1791 set((state) => applyOrchestrationEvents(state, events, environmentId)),
15481792 setError: (threadId, error) => set((state) => setError(state, threadId, error)),
15491793 setThreadBranch: (threadId, branch, worktreePath) =>
15501794 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
apps/web/src/storeSelectors.ts+68-27
......@@ -1,5 +1,11 @@
1import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts";
2import { type AppState } from "./store";
1import {
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";
39import {
410 type ChatMessage,
511 type Project,
......@@ -30,54 +36,61 @@ function collectByIds<TKey extends string, TValue>(
3036 });
3137}
3238
33export function createProjectSelector(
34 projectId: ProjectId | null | undefined,
39export function createProjectSelectorByRef(
40 ref: ScopedProjectRef | null | undefined,
3541): (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;
3744}
3845
39export function createSidebarThreadSummarySelector(
40 threadId: ThreadId | null | undefined,
46export function createSidebarThreadSummarySelectorByRef(
47 ref: ScopedThreadRef | null | undefined,
4148): (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;
4353}
4454
45export function createThreadSelector(
46 threadId: ThreadId | null | undefined,
55function createScopedThreadSelector(
56 resolveRef: (state: AppState) => ScopedThreadRef | null | undefined,
4757): (state: AppState) => Thread | undefined {
48 let previousShell: AppState["threadShellById"][ThreadId] | undefined;
58 let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined;
4959 let previousSession: ThreadSession | null | undefined;
5060 let previousTurnState: ThreadTurnState | undefined;
5161 let previousMessageIds: MessageId[] | undefined;
52 let previousMessagesById: AppState["messageByThreadId"][ThreadId] | undefined;
62 let previousMessagesById: EnvironmentState["messageByThreadId"][ThreadId] | undefined;
5363 let previousActivityIds: string[] | undefined;
54 let previousActivitiesById: AppState["activityByThreadId"][ThreadId] | undefined;
64 let previousActivitiesById: EnvironmentState["activityByThreadId"][ThreadId] | undefined;
5565 let previousProposedPlanIds: string[] | undefined;
56 let previousProposedPlansById: AppState["proposedPlanByThreadId"][ThreadId] | undefined;
66 let previousProposedPlansById: EnvironmentState["proposedPlanByThreadId"][ThreadId] | undefined;
5767 let previousTurnDiffIds: TurnId[] | undefined;
58 let previousTurnDiffsById: AppState["turnDiffSummaryByThreadId"][ThreadId] | undefined;
68 let previousTurnDiffsById: EnvironmentState["turnDiffSummaryByThreadId"][ThreadId] | undefined;
5969 let previousThread: Thread | undefined;
6070
6171 return (state) => {
62 if (!threadId) {
72 const ref = resolveRef(state);
73 if (!ref) {
6374 return undefined;
6475 }
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];
6780 if (!shell) {
6881 return undefined;
6982 }
7083
71 const session = state.threadSessionById[threadId] ?? null;
72 const turnState = state.threadTurnStateById[threadId];
73 const messageIds = state.messageIdsByThreadId[threadId];
74 const messageById = state.messageByThreadId[threadId];
75 const activityIds = state.activityIdsByThreadId[threadId];
76 const activityById = state.activityByThreadId[threadId];
77 const proposedPlanIds = state.proposedPlanIdsByThreadId[threadId];
78 const proposedPlanById = state.proposedPlanByThreadId[threadId];
79 const turnDiffIds = state.turnDiffIdsByThreadId[threadId];
80 const turnDiffById = state.turnDiffSummaryByThreadId[threadId];
84 const session = environmentState.threadSessionById[threadId] ?? null;
85 const turnState = environmentState.threadTurnStateById[threadId];
86 const messageIds = environmentState.messageIdsByThreadId[threadId];
87 const messageById = environmentState.messageByThreadId[threadId];
88 const activityIds = environmentState.activityIdsByThreadId[threadId];
89 const activityById = environmentState.activityByThreadId[threadId];
90 const proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId];
91 const proposedPlanById = environmentState.proposedPlanByThreadId[threadId];
92 const turnDiffIds = environmentState.turnDiffIdsByThreadId[threadId];
93 const turnDiffById = environmentState.turnDiffSummaryByThreadId[threadId];
8194
8295 if (
8396 previousThread &&
......@@ -144,3 +157,31 @@ export function createThreadSelector(
144157 return previousThread;
145158 };
146159}
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 @@
1import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime";
12import { ThreadId, type TerminalEvent } from "@t3tools/contracts";
23import { beforeEach, describe, expect, it } from "vitest";
34
45import {
6 migratePersistedTerminalStateStoreState,
57 selectTerminalEventEntries,
68 selectThreadTerminalState,
79 useTerminalStateStore,
810} from "./terminalStateStore";
911
1012const 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
1216function makeTerminalEvent(
1317 type: TerminalEvent["type"],
......@@ -56,8 +60,8 @@ describe("terminalStateStore actions", () => {
5660 beforeEach(() => {
5761 useTerminalStateStore.persist.clearStorage();
5862 useTerminalStateStore.setState({
59 terminalStateByThreadId: {},
60 terminalLaunchContextByThreadId: {},
63 terminalStateByThreadKey: {},
64 terminalLaunchContextByThreadKey: {},
6165 terminalEventEntriesByKey: {},
6266 nextTerminalEventId: 1,
6367 });
......@@ -65,8 +69,8 @@ describe("terminalStateStore actions", () => {
6569
6670 it("returns a closed default terminal state for unknown threads", () => {
6771 const terminalState = selectThreadTerminalState(
68 useTerminalStateStore.getState().terminalStateByThreadId,
69 THREAD_ID,
72 useTerminalStateStore.getState().terminalStateByThreadKey,
73 THREAD_REF,
7074 );
7175 expect(terminalState).toEqual({
7276 terminalOpen: false,
......@@ -81,12 +85,12 @@ describe("terminalStateStore actions", () => {
8185
8286 it("opens and splits terminals into the active group", () => {
8387 const store = useTerminalStateStore.getState();
84 store.setTerminalOpen(THREAD_ID, true);
85 store.splitTerminal(THREAD_ID, "terminal-2");
88 store.setTerminalOpen(THREAD_REF, true);
89 store.splitTerminal(THREAD_REF, "terminal-2");
8690
8791 const terminalState = selectThreadTerminalState(
88 useTerminalStateStore.getState().terminalStateByThreadId,
89 THREAD_ID,
92 useTerminalStateStore.getState().terminalStateByThreadKey,
93 THREAD_REF,
9094 );
9195 expect(terminalState.terminalOpen).toBe(true);
9296 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
......@@ -98,14 +102,14 @@ describe("terminalStateStore actions", () => {
98102
99103 it("caps splits at four terminals per group", () => {
100104 const store = useTerminalStateStore.getState();
101 store.splitTerminal(THREAD_ID, "terminal-2");
102 store.splitTerminal(THREAD_ID, "terminal-3");
103 store.splitTerminal(THREAD_ID, "terminal-4");
104 store.splitTerminal(THREAD_ID, "terminal-5");
105 store.splitTerminal(THREAD_REF, "terminal-2");
106 store.splitTerminal(THREAD_REF, "terminal-3");
107 store.splitTerminal(THREAD_REF, "terminal-4");
108 store.splitTerminal(THREAD_REF, "terminal-5");
105109
106110 const terminalState = selectThreadTerminalState(
107 useTerminalStateStore.getState().terminalStateByThreadId,
108 THREAD_ID,
111 useTerminalStateStore.getState().terminalStateByThreadKey,
112 THREAD_REF,
109113 );
110114 expect(terminalState.terminalIds).toEqual([
111115 "default",
......@@ -119,11 +123,11 @@ describe("terminalStateStore actions", () => {
119123 });
120124
121125 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
124128 const terminalState = selectThreadTerminalState(
125 useTerminalStateStore.getState().terminalStateByThreadId,
126 THREAD_ID,
129 useTerminalStateStore.getState().terminalStateByThreadKey,
130 THREAD_REF,
127131 );
128132 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
129133 expect(terminalState.activeTerminalId).toBe("terminal-2");
......@@ -136,11 +140,11 @@ describe("terminalStateStore actions", () => {
136140
137141 it("ensures unknown server terminals are registered, opened, and activated", () => {
138142 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
141145 const terminalState = selectThreadTerminalState(
142 useTerminalStateStore.getState().terminalStateByThreadId,
143 THREAD_ID,
146 useTerminalStateStore.getState().terminalStateByThreadKey,
147 THREAD_REF,
144148 );
145149 expect(terminalState.terminalOpen).toBe(true);
146150 expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]);
......@@ -151,69 +155,111 @@ describe("terminalStateStore actions", () => {
151155 ]);
152156 });
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", () => {
155159 const store = useTerminalStateStore.getState();
156 store.splitTerminal(THREAD_ID, "terminal-2");
157 store.splitTerminal(THREAD_ID, "terminal-3");
158 store.splitTerminal(THREAD_ID, "terminal-4");
159 store.newTerminal(THREAD_ID, "terminal-5");
160 store.newTerminal(THREAD_ID, "terminal-6");
160 store.setTerminalOpen(THREAD_REF, true);
161 store.newTerminal(OTHER_THREAD_REF, "env-b-terminal");
161162
162 const terminalState = selectThreadTerminalState(
163 useTerminalStateStore.getState().terminalStateByThreadId,
164 THREAD_ID,
163 expect(
164 selectThreadTerminalState(
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,
165202 );
166 expect(terminalState.terminalIds).toEqual([
167 "default",
168 "terminal-2",
169 "terminal-3",
170 "terminal-4",
171 "terminal-5",
172 "terminal-6",
173 ]);
174 expect(terminalState.terminalGroups).toEqual([
175 { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] },
176 { id: "group-terminal-5", terminalIds: ["terminal-5"] },
177 { id: "group-terminal-6", terminalIds: ["terminal-6"] },
178 ]);
203
204 expect(migrated).toEqual({
205 terminalStateByThreadKey: {
206 [scopedThreadKey(THREAD_REF)]: {
207 terminalOpen: true,
208 terminalHeight: 320,
209 terminalIds: ["default"],
210 runningTerminalIds: [],
211 activeTerminalId: "default",
212 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
213 activeTerminalGroupId: "group-default",
214 },
215 },
216 });
179217 });
180218
181219 it("tracks and clears terminal subprocess activity", () => {
182220 const store = useTerminalStateStore.getState();
183 store.splitTerminal(THREAD_ID, "terminal-2");
184 store.setTerminalActivity(THREAD_ID, "terminal-2", true);
221 store.splitTerminal(THREAD_REF, "terminal-2");
222 store.setTerminalActivity(THREAD_REF, "terminal-2", true);
185223 expect(
186 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)
187 .runningTerminalIds,
224 selectThreadTerminalState(
225 useTerminalStateStore.getState().terminalStateByThreadKey,
226 THREAD_REF,
227 ).runningTerminalIds,
188228 ).toEqual(["terminal-2"]);
189229
190 store.setTerminalActivity(THREAD_ID, "terminal-2", false);
230 store.setTerminalActivity(THREAD_REF, "terminal-2", false);
191231 expect(
192 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)
193 .runningTerminalIds,
232 selectThreadTerminalState(
233 useTerminalStateStore.getState().terminalStateByThreadKey,
234 THREAD_REF,
235 ).runningTerminalIds,
194236 ).toEqual([]);
195237 });
196238
197239 it("resets to default and clears persisted entry when closing the last terminal", () => {
198240 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();
202243 expect(
203 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)
204 .terminalIds,
244 useTerminalStateStore.getState().terminalStateByThreadKey[scopedThreadKey(THREAD_REF)],
245 ).toBeUndefined();
246 expect(
247 selectThreadTerminalState(
248 useTerminalStateStore.getState().terminalStateByThreadKey,
249 THREAD_REF,
250 ).terminalIds,
205251 ).toEqual(["default"]);
206252 });
207253
208254 it("keeps a valid active terminal after closing an active split terminal", () => {
209255 const store = useTerminalStateStore.getState();
210 store.splitTerminal(THREAD_ID, "terminal-2");
211 store.splitTerminal(THREAD_ID, "terminal-3");
212 store.closeTerminal(THREAD_ID, "terminal-3");
256 store.splitTerminal(THREAD_REF, "terminal-2");
257 store.splitTerminal(THREAD_REF, "terminal-3");
258 store.closeTerminal(THREAD_REF, "terminal-3");
213259
214260 const terminalState = selectThreadTerminalState(
215 useTerminalStateStore.getState().terminalStateByThreadId,
216 THREAD_ID,
261 useTerminalStateStore.getState().terminalStateByThreadKey,
262 THREAD_REF,
217263 );
218264 expect(terminalState.activeTerminalId).toBe("terminal-2");
219265 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
......@@ -224,12 +270,12 @@ describe("terminalStateStore actions", () => {
224270
225271 it("buffers terminal events outside persisted terminal UI state", () => {
226272 const store = useTerminalStateStore.getState();
227 store.recordTerminalEvent(makeTerminalEvent("output"));
228 store.recordTerminalEvent(makeTerminalEvent("activity"));
273 store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output"));
274 store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("activity"));
229275
230276 const entries = selectTerminalEventEntries(
231277 useTerminalStateStore.getState().terminalEventEntriesByKey,
232 THREAD_ID,
278 THREAD_REF,
233279 "default",
234280 );
235281
......@@ -241,6 +287,7 @@ describe("terminalStateStore actions", () => {
241287 it("applies started terminal events to terminal state, launch context, and event buffer", () => {
242288 const store = useTerminalStateStore.getState();
243289 store.applyTerminalEvent(
290 THREAD_REF,
244291 makeTerminalEvent("started", {
245292 terminalId: "setup-bootstrap",
246293 snapshot: {
......@@ -259,19 +306,23 @@ describe("terminalStateStore actions", () => {
259306 );
260307
261308 const terminalState = selectThreadTerminalState(
262 useTerminalStateStore.getState().terminalStateByThreadId,
263 THREAD_ID,
309 useTerminalStateStore.getState().terminalStateByThreadKey,
310 THREAD_REF,
264311 );
265312 const entries = selectTerminalEventEntries(
266313 useTerminalStateStore.getState().terminalEventEntriesByKey,
267 THREAD_ID,
314 THREAD_REF,
268315 "setup-bootstrap",
269316 );
270317
271318 expect(terminalState.terminalOpen).toBe(true);
272319 expect(terminalState.activeTerminalId).toBe("setup-bootstrap");
273320 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({
275326 cwd: "/tmp/worktree",
276327 worktreePath: "/tmp/worktree",
277328 });
......@@ -281,20 +332,24 @@ describe("terminalStateStore actions", () => {
281332
282333 it("applies activity and exited terminal events to subprocess state while buffering events", () => {
283334 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
286337 store.applyTerminalEvent(
338 THREAD_REF,
287339 makeTerminalEvent("activity", {
288340 terminalId: "terminal-2",
289341 hasRunningSubprocess: true,
290342 }),
291343 );
292344 expect(
293 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)
294 .runningTerminalIds,
345 selectThreadTerminalState(
346 useTerminalStateStore.getState().terminalStateByThreadKey,
347 THREAD_REF,
348 ).runningTerminalIds,
295349 ).toEqual(["terminal-2"]);
296350
297351 store.applyTerminalEvent(
352 THREAD_REF,
298353 makeTerminalEvent("exited", {
299354 terminalId: "terminal-2",
300355 exitCode: 0,
......@@ -303,12 +358,12 @@ describe("terminalStateStore actions", () => {
303358 );
304359
305360 const terminalState = selectThreadTerminalState(
306 useTerminalStateStore.getState().terminalStateByThreadId,
307 THREAD_ID,
361 useTerminalStateStore.getState().terminalStateByThreadKey,
362 THREAD_REF,
308363 );
309364 const entries = selectTerminalEventEntries(
310365 useTerminalStateStore.getState().terminalEventEntriesByKey,
311 THREAD_ID,
366 THREAD_REF,
312367 "terminal-2",
313368 );
314369
......@@ -318,12 +373,12 @@ describe("terminalStateStore actions", () => {
318373
319374 it("clears buffered terminal events when a thread terminal state is removed", () => {
320375 const store = useTerminalStateStore.getState();
321 store.recordTerminalEvent(makeTerminalEvent("output"));
322 store.removeTerminalState(THREAD_ID);
376 store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output"));
377 store.removeTerminalState(THREAD_REF);
323378
324379 const entries = selectTerminalEventEntries(
325380 useTerminalStateStore.getState().terminalEventEntriesByKey,
326 THREAD_ID,
381 THREAD_REF,
327382 "default",
328383 );
329384
......@@ -334,7 +389,7 @@ describe("terminalStateStore actions", () => {
334389 const store = useTerminalStateStore.getState();
335390 const before = useTerminalStateStore.getState();
336391
337 store.clearTerminalState(THREAD_ID);
392 store.clearTerminalState(THREAD_REF);
338393
339394 expect(useTerminalStateStore.getState()).toBe(before);
340395 });
apps/web/src/terminalStateStore.ts+165-127
......@@ -1,11 +1,12 @@
11/**
2 * Single Zustand store for terminal UI state keyed by threadId.
2 * Single Zustand store for terminal UI state keyed by scoped thread identity.
33 *
44 * Terminal transition helpers are intentionally private to keep the public
55 * API constrained to store actions/selectors.
66 */
77
8import { ThreadId, type TerminalEvent } from "@t3tools/contracts";
8import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime";
9import { type ScopedThreadRef, type TerminalEvent } from "@t3tools/contracts";
910import { create } from "zustand";
1011import { createJSONStorage, persist } from "zustand/middleware";
1112import { resolveStorage } from "./lib/storage";
......@@ -41,6 +42,26 @@ const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
4142const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = [];
4243const 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
4465function createTerminalStateStorage() {
4566 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
4667}
......@@ -240,8 +261,12 @@ function isValidTerminalId(terminalId: string): boolean {
240261 return terminalId.trim().length > 0;
241262}
242263
243function terminalEventBufferKey(threadId: ThreadId, terminalId: string): string {
244 return `${threadId}\u0000${terminalId}`;
264function terminalThreadKey(threadRef: ScopedThreadRef): string {
265 return scopedThreadKey(threadRef);
266}
267
268function terminalEventBufferKey(threadRef: ScopedThreadRef, terminalId: string): string {
269 return `${terminalThreadKey(threadRef)}\u0000${terminalId}`;
245270}
246271
247272function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
......@@ -254,9 +279,10 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[
254279function appendTerminalEventEntry(
255280 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>,
256281 nextTerminalEventId: number,
282 threadRef: ScopedThreadRef,
257283 event: TerminalEvent,
258284) {
259 const key = terminalEventBufferKey(ThreadId.makeUnsafe(event.threadId), event.terminalId);
285 const key = terminalEventBufferKey(threadRef, event.terminalId);
260286 const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES;
261287 const nextEntry: TerminalEventEntry = {
262288 id: nextTerminalEventId,
......@@ -484,125 +510,129 @@ function setThreadTerminalActivity(
484510}
485511
486512export function selectThreadTerminalState(
487 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,
488 threadId: ThreadId,
513 terminalStateByThreadKey: Record<string, ThreadTerminalState>,
514 threadRef: ScopedThreadRef | null | undefined,
489515): ThreadTerminalState {
490 if (threadId.length === 0) {
516 if (!threadRef || threadRef.threadId.length === 0) {
491517 return getDefaultThreadTerminalState();
492518 }
493 return terminalStateByThreadId[threadId] ?? getDefaultThreadTerminalState();
519 return terminalStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalState();
494520}
495521
496function updateTerminalStateByThreadId(
497 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,
498 threadId: ThreadId,
522function updateTerminalStateByThreadKey(
523 terminalStateByThreadKey: Record<string, ThreadTerminalState>,
524 threadRef: ScopedThreadRef,
499525 updater: (state: ThreadTerminalState) => ThreadTerminalState,
500): Record<ThreadId, ThreadTerminalState> {
501 if (threadId.length === 0) {
502 return terminalStateByThreadId;
526): Record<string, ThreadTerminalState> {
527 if (threadRef.threadId.length === 0) {
528 return terminalStateByThreadKey;
503529 }
504530
505 const current = selectThreadTerminalState(terminalStateByThreadId, threadId);
531 const threadKey = terminalThreadKey(threadRef);
532 const current = selectThreadTerminalState(terminalStateByThreadKey, threadRef);
506533 const next = updater(current);
507534 if (next === current) {
508 return terminalStateByThreadId;
535 return terminalStateByThreadKey;
509536 }
510537
511538 if (isDefaultThreadTerminalState(next)) {
512 if (terminalStateByThreadId[threadId] === undefined) {
513 return terminalStateByThreadId;
539 if (terminalStateByThreadKey[threadKey] === undefined) {
540 return terminalStateByThreadKey;
514541 }
515 const { [threadId]: _removed, ...rest } = terminalStateByThreadId;
516 return rest as Record<ThreadId, ThreadTerminalState>;
542 const { [threadKey]: _removed, ...rest } = terminalStateByThreadKey;
543 return rest;
517544 }
518545
519546 return {
520 ...terminalStateByThreadId,
521 [threadId]: next,
547 ...terminalStateByThreadKey,
548 [threadKey]: next,
522549 };
523550}
524551
525552export function selectTerminalEventEntries(
526553 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>,
527 threadId: ThreadId,
554 threadRef: ScopedThreadRef | null | undefined,
528555 terminalId: string,
529556): ReadonlyArray<TerminalEventEntry> {
530 if (threadId.length === 0 || terminalId.trim().length === 0) {
557 if (!threadRef || threadRef.threadId.length === 0 || terminalId.trim().length === 0) {
531558 return EMPTY_TERMINAL_EVENT_ENTRIES;
532559 }
533560 return (
534 terminalEventEntriesByKey[terminalEventBufferKey(threadId, terminalId)] ??
561 terminalEventEntriesByKey[terminalEventBufferKey(threadRef, terminalId)] ??
535562 EMPTY_TERMINAL_EVENT_ENTRIES
536563 );
537564}
538565
539566interface TerminalStateStoreState {
540 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;
541 terminalLaunchContextByThreadId: Record<ThreadId, ThreadTerminalLaunchContext>;
567 terminalStateByThreadKey: Record<string, ThreadTerminalState>;
568 terminalLaunchContextByThreadKey: Record<string, ThreadTerminalLaunchContext>;
542569 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>;
543570 nextTerminalEventId: number;
544 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;
545 setTerminalHeight: (threadId: ThreadId, height: number) => void;
546 splitTerminal: (threadId: ThreadId, terminalId: string) => void;
547 newTerminal: (threadId: ThreadId, terminalId: string) => void;
571 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
572 setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void;
573 splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
574 newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
548575 ensureTerminal: (
549 threadId: ThreadId,
576 threadRef: ScopedThreadRef,
550577 terminalId: string,
551578 options?: { open?: boolean; active?: boolean },
552579 ) => void;
553 setActiveTerminal: (threadId: ThreadId, terminalId: string) => void;
554 closeTerminal: (threadId: ThreadId, terminalId: string) => void;
555 setTerminalLaunchContext: (threadId: ThreadId, context: ThreadTerminalLaunchContext) => void;
556 clearTerminalLaunchContext: (threadId: ThreadId) => void;
580 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
581 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
582 setTerminalLaunchContext: (
583 threadRef: ScopedThreadRef,
584 context: ThreadTerminalLaunchContext,
585 ) => void;
586 clearTerminalLaunchContext: (threadRef: ScopedThreadRef) => void;
557587 setTerminalActivity: (
558 threadId: ThreadId,
588 threadRef: ScopedThreadRef,
559589 terminalId: string,
560590 hasRunningSubprocess: boolean,
561591 ) => void;
562 recordTerminalEvent: (event: TerminalEvent) => void;
563 applyTerminalEvent: (event: TerminalEvent) => void;
564 clearTerminalState: (threadId: ThreadId) => void;
565 removeTerminalState: (threadId: ThreadId) => void;
566 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
592 recordTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void;
593 applyTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void;
594 clearTerminalState: (threadRef: ScopedThreadRef) => void;
595 removeTerminalState: (threadRef: ScopedThreadRef) => void;
596 removeOrphanedTerminalStates: (activeThreadKeys: Set<string>) => void;
567597}
568598
569599export const useTerminalStateStore = create<TerminalStateStoreState>()(
570600 persist(
571601 (set) => {
572602 const updateTerminal = (
573 threadId: ThreadId,
603 threadRef: ScopedThreadRef,
574604 updater: (state: ThreadTerminalState) => ThreadTerminalState,
575605 ) => {
576606 set((state) => {
577 const nextTerminalStateByThreadId = updateTerminalStateByThreadId(
578 state.terminalStateByThreadId,
579 threadId,
607 const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
608 state.terminalStateByThreadKey,
609 threadRef,
580610 updater,
581611 );
582 if (nextTerminalStateByThreadId === state.terminalStateByThreadId) {
612 if (nextTerminalStateByThreadKey === state.terminalStateByThreadKey) {
583613 return state;
584614 }
585615 return {
586 terminalStateByThreadId: nextTerminalStateByThreadId,
616 terminalStateByThreadKey: nextTerminalStateByThreadKey,
587617 };
588618 });
589619 };
590620
591621 return {
592 terminalStateByThreadId: {},
593 terminalLaunchContextByThreadId: {},
622 terminalStateByThreadKey: {},
623 terminalLaunchContextByThreadKey: {},
594624 terminalEventEntriesByKey: {},
595625 nextTerminalEventId: 1,
596 setTerminalOpen: (threadId, open) =>
597 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),
598 setTerminalHeight: (threadId, height) =>
599 updateTerminal(threadId, (state) => setThreadTerminalHeight(state, height)),
600 splitTerminal: (threadId, terminalId) =>
601 updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)),
602 newTerminal: (threadId, terminalId) =>
603 updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)),
604 ensureTerminal: (threadId, terminalId, options) =>
605 updateTerminal(threadId, (state) => {
626 setTerminalOpen: (threadRef, open) =>
627 updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)),
628 setTerminalHeight: (threadRef, height) =>
629 updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)),
630 splitTerminal: (threadRef, terminalId) =>
631 updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)),
632 newTerminal: (threadRef, terminalId) =>
633 updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)),
634 ensureTerminal: (threadRef, terminalId, options) =>
635 updateTerminal(threadRef, (state) => {
606636 let nextState = state;
607637 if (!state.terminalIds.includes(terminalId)) {
608638 nextState = newThreadTerminal(nextState, terminalId);
......@@ -622,47 +652,49 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
622652 }
623653 return normalizeThreadTerminalState(nextState);
624654 }),
625 setActiveTerminal: (threadId, terminalId) =>
626 updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)),
627 closeTerminal: (threadId, terminalId) =>
628 updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)),
629 setTerminalLaunchContext: (threadId, context) =>
655 setActiveTerminal: (threadRef, terminalId) =>
656 updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)),
657 closeTerminal: (threadRef, terminalId) =>
658 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)),
659 setTerminalLaunchContext: (threadRef, context) =>
630660 set((state) => ({
631 terminalLaunchContextByThreadId: {
632 ...state.terminalLaunchContextByThreadId,
633 [threadId]: context,
661 terminalLaunchContextByThreadKey: {
662 ...state.terminalLaunchContextByThreadKey,
663 [terminalThreadKey(threadRef)]: context,
634664 },
635665 })),
636 clearTerminalLaunchContext: (threadId) =>
666 clearTerminalLaunchContext: (threadRef) =>
637667 set((state) => {
638 if (!state.terminalLaunchContextByThreadId[threadId]) {
668 const threadKey = terminalThreadKey(threadRef);
669 if (!state.terminalLaunchContextByThreadKey[threadKey]) {
639670 return state;
640671 }
641 const { [threadId]: _removed, ...rest } = state.terminalLaunchContextByThreadId;
642 return { terminalLaunchContextByThreadId: rest };
672 const { [threadKey]: _removed, ...rest } = state.terminalLaunchContextByThreadKey;
673 return { terminalLaunchContextByThreadKey: rest };
643674 }),
644 setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>
645 updateTerminal(threadId, (state) =>
675 setTerminalActivity: (threadRef, terminalId, hasRunningSubprocess) =>
676 updateTerminal(threadRef, (state) =>
646677 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),
647678 ),
648 recordTerminalEvent: (event) =>
679 recordTerminalEvent: (threadRef, event) =>
649680 set((state) =>
650681 appendTerminalEventEntry(
651682 state.terminalEventEntriesByKey,
652683 state.nextTerminalEventId,
684 threadRef,
653685 event,
654686 ),
655687 ),
656 applyTerminalEvent: (event) =>
688 applyTerminalEvent: (threadRef, event) =>
657689 set((state) => {
658 const threadId = ThreadId.makeUnsafe(event.threadId);
659 let nextTerminalStateByThreadId = state.terminalStateByThreadId;
660 let nextTerminalLaunchContextByThreadId = state.terminalLaunchContextByThreadId;
690 const threadKey = terminalThreadKey(threadRef);
691 let nextTerminalStateByThreadKey = state.terminalStateByThreadKey;
692 let nextTerminalLaunchContextByThreadKey = state.terminalLaunchContextByThreadKey;
661693
662694 if (event.type === "started" || event.type === "restarted") {
663 nextTerminalStateByThreadId = updateTerminalStateByThreadId(
664 nextTerminalStateByThreadId,
665 threadId,
695 nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
696 nextTerminalStateByThreadKey,
697 threadRef,
666698 (current) => {
667699 let nextState = current;
668700 if (!current.terminalIds.includes(event.terminalId)) {
......@@ -673,17 +705,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
673705 return normalizeThreadTerminalState(nextState);
674706 },
675707 );
676 nextTerminalLaunchContextByThreadId = {
677 ...nextTerminalLaunchContextByThreadId,
678 [threadId]: launchContextFromStartEvent(event),
708 nextTerminalLaunchContextByThreadKey = {
709 ...nextTerminalLaunchContextByThreadKey,
710 [threadKey]: launchContextFromStartEvent(event),
679711 };
680712 }
681713
682714 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);
683715 if (hasRunningSubprocess !== null) {
684 nextTerminalStateByThreadId = updateTerminalStateByThreadId(
685 nextTerminalStateByThreadId,
686 threadId,
716 nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
717 nextTerminalStateByThreadKey,
718 threadRef,
687719 (current) =>
688720 setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess),
689721 );
......@@ -692,54 +724,59 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
692724 const nextEventState = appendTerminalEventEntry(
693725 state.terminalEventEntriesByKey,
694726 state.nextTerminalEventId,
727 threadRef,
695728 event,
696729 );
697730
698731 return {
699 terminalStateByThreadId: nextTerminalStateByThreadId,
700 terminalLaunchContextByThreadId: nextTerminalLaunchContextByThreadId,
732 terminalStateByThreadKey: nextTerminalStateByThreadKey,
733 terminalLaunchContextByThreadKey: nextTerminalLaunchContextByThreadKey,
701734 ...nextEventState,
702735 };
703736 }),
704 clearTerminalState: (threadId) =>
737 clearTerminalState: (threadRef) =>
705738 set((state) => {
706 const nextTerminalStateByThreadId = updateTerminalStateByThreadId(
707 state.terminalStateByThreadId,
708 threadId,
739 const threadKey = terminalThreadKey(threadRef);
740 const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey(
741 state.terminalStateByThreadKey,
742 threadRef,
709743 () => createDefaultThreadTerminalState(),
710744 );
711 const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined;
712 const { [threadId]: _removed, ...remainingLaunchContexts } =
713 state.terminalLaunchContextByThreadId;
745 const hadLaunchContext =
746 state.terminalLaunchContextByThreadKey[threadKey] !== undefined;
747 const { [threadKey]: _removed, ...remainingLaunchContexts } =
748 state.terminalLaunchContextByThreadKey;
714749 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
715750 let removedEventEntries = false;
716751 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
717 if (key.startsWith(`${threadId}\u0000`)) {
752 if (key.startsWith(`${threadKey}\u0000`)) {
718753 delete nextTerminalEventEntriesByKey[key];
719754 removedEventEntries = true;
720755 }
721756 }
722757 if (
723 nextTerminalStateByThreadId === state.terminalStateByThreadId &&
758 nextTerminalStateByThreadKey === state.terminalStateByThreadKey &&
724759 !hadLaunchContext &&
725760 !removedEventEntries
726761 ) {
727762 return state;
728763 }
729764 return {
730 terminalStateByThreadId: nextTerminalStateByThreadId,
731 terminalLaunchContextByThreadId: remainingLaunchContexts,
765 terminalStateByThreadKey: nextTerminalStateByThreadKey,
766 terminalLaunchContextByThreadKey: remainingLaunchContexts,
732767 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
733768 };
734769 }),
735 removeTerminalState: (threadId) =>
770 removeTerminalState: (threadRef) =>
736771 set((state) => {
737 const hadTerminalState = state.terminalStateByThreadId[threadId] !== undefined;
738 const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined;
772 const threadKey = terminalThreadKey(threadRef);
773 const hadTerminalState = state.terminalStateByThreadKey[threadKey] !== undefined;
774 const hadLaunchContext =
775 state.terminalLaunchContextByThreadKey[threadKey] !== undefined;
739776 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
740777 let removedEventEntries = false;
741778 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
742 if (key.startsWith(`${threadId}\u0000`)) {
779 if (key.startsWith(`${threadKey}\u0000`)) {
743780 delete nextTerminalEventEntriesByKey[key];
744781 removedEventEntries = true;
745782 }
......@@ -747,29 +784,29 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
747784 if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) {
748785 return state;
749786 }
750 const nextTerminalStateByThreadId = { ...state.terminalStateByThreadId };
751 delete nextTerminalStateByThreadId[threadId];
752 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId };
753 delete nextLaunchContexts[threadId];
787 const nextTerminalStateByThreadKey = { ...state.terminalStateByThreadKey };
788 delete nextTerminalStateByThreadKey[threadKey];
789 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey };
790 delete nextLaunchContexts[threadKey];
754791 return {
755 terminalStateByThreadId: nextTerminalStateByThreadId,
756 terminalLaunchContextByThreadId: nextLaunchContexts,
792 terminalStateByThreadKey: nextTerminalStateByThreadKey,
793 terminalLaunchContextByThreadKey: nextLaunchContexts,
757794 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
758795 };
759796 }),
760 removeOrphanedTerminalStates: (activeThreadIds) =>
797 removeOrphanedTerminalStates: (activeThreadKeys) =>
761798 set((state) => {
762 const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(
763 (id) => !activeThreadIds.has(id as ThreadId),
799 const orphanedIds = Object.keys(state.terminalStateByThreadKey).filter(
800 (key) => !activeThreadKeys.has(key),
764801 );
765802 const orphanedLaunchContextIds = Object.keys(
766 state.terminalLaunchContextByThreadId,
767 ).filter((id) => !activeThreadIds.has(id as ThreadId));
803 state.terminalLaunchContextByThreadKey,
804 ).filter((key) => !activeThreadKeys.has(key));
768805 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
769806 let removedEventEntries = false;
770807 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
771 const [threadId] = key.split("\u0000");
772 if (threadId && !activeThreadIds.has(threadId as ThreadId)) {
808 const [threadKey] = key.split("\u0000");
809 if (threadKey && !activeThreadKeys.has(threadKey)) {
773810 delete nextTerminalEventEntriesByKey[key];
774811 removedEventEntries = true;
775812 }
......@@ -781,17 +818,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
781818 ) {
782819 return state;
783820 }
784 const next = { ...state.terminalStateByThreadId };
821 const next = { ...state.terminalStateByThreadKey };
785822 for (const id of orphanedIds) {
786 delete next[id as ThreadId];
823 delete next[id];
787824 }
788 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId };
825 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey };
789826 for (const id of orphanedLaunchContextIds) {
790 delete nextLaunchContexts[id as ThreadId];
827 delete nextLaunchContexts[id];
791828 }
792829 return {
793 terminalStateByThreadId: next,
794 terminalLaunchContextByThreadId: nextLaunchContexts,
830 terminalStateByThreadKey: next,
831 terminalLaunchContextByThreadKey: nextLaunchContexts,
795832 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
796833 };
797834 }),
......@@ -799,10 +836,11 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
799836 },
800837 {
801838 name: TERMINAL_STATE_STORAGE_KEY,
802 version: 1,
839 version: 2,
803840 storage: createJSONStorage(createTerminalStateStorage),
841 migrate: migratePersistedTerminalStateStoreState,
804842 partialize: (state) => ({
805 terminalStateByThreadId: state.terminalStateByThreadId,
843 terminalStateByThreadKey: state.terminalStateByThreadKey,
806844 }),
807845 },
808846 ),
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", () => {
2121 useThreadSelectionStore.getState().toggleThread(THREAD_A);
2222
2323 const state = useThreadSelectionStore.getState();
24 expect(state.selectedThreadIds.has(THREAD_A)).toBe(true);
25 expect(state.selectedThreadIds.size).toBe(1);
26 expect(state.anchorThreadId).toBe(THREAD_A);
24 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true);
25 expect(state.selectedThreadKeys.size).toBe(1);
26 expect(state.anchorThreadKey).toBe(THREAD_A);
2727 });
2828
2929 it("removes a thread that is already selected", () => {
......@@ -32,8 +32,8 @@ describe("threadSelectionStore", () => {
3232 store.toggleThread(THREAD_A);
3333
3434 const state = useThreadSelectionStore.getState();
35 expect(state.selectedThreadIds.has(THREAD_A)).toBe(false);
36 expect(state.selectedThreadIds.size).toBe(0);
35 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(false);
36 expect(state.selectedThreadKeys.size).toBe(0);
3737 });
3838
3939 it("preserves existing selections when toggling a new thread", () => {
......@@ -42,9 +42,9 @@ describe("threadSelectionStore", () => {
4242 store.toggleThread(THREAD_B);
4343
4444 const state = useThreadSelectionStore.getState();
45 expect(state.selectedThreadIds.has(THREAD_A)).toBe(true);
46 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
47 expect(state.selectedThreadIds.size).toBe(2);
45 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true);
46 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
47 expect(state.selectedThreadKeys.size).toBe(2);
4848 });
4949
5050 it("sets anchor to the newly added thread", () => {
......@@ -52,7 +52,7 @@ describe("threadSelectionStore", () => {
5252 store.toggleThread(THREAD_A);
5353 store.toggleThread(THREAD_B);
5454
55 expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B);
55 expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B);
5656 });
5757
5858 it("preserves anchor when deselecting a non-anchor thread", () => {
......@@ -61,7 +61,7 @@ describe("threadSelectionStore", () => {
6161 store.toggleThread(THREAD_B);
6262 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);
6565 });
6666 });
6767
......@@ -70,8 +70,8 @@ describe("threadSelectionStore", () => {
7070 useThreadSelectionStore.getState().setAnchor(THREAD_B);
7171
7272 const state = useThreadSelectionStore.getState();
73 expect(state.anchorThreadId).toBe(THREAD_B);
74 expect(state.selectedThreadIds.size).toBe(0);
73 expect(state.anchorThreadKey).toBe(THREAD_B);
74 expect(state.selectedThreadKeys.size).toBe(0);
7575 });
7676
7777 it("enables range select from a plain-click anchor", () => {
......@@ -80,10 +80,10 @@ describe("threadSelectionStore", () => {
8080 store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D
8181
8282 const state = useThreadSelectionStore.getState();
83 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
84 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
85 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);
86 expect(state.selectedThreadIds.size).toBe(3);
83 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
84 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
85 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
86 expect(state.selectedThreadKeys.size).toBe(3);
8787 });
8888
8989 it("is a no-op when anchor is already set to the same thread", () => {
......@@ -105,8 +105,8 @@ describe("threadSelectionStore", () => {
105105 store.setAnchor(THREAD_C);
106106
107107 const state = useThreadSelectionStore.getState();
108 expect(state.anchorThreadId).toBe(THREAD_C);
109 expect(state.selectedThreadIds.size).toBe(0);
108 expect(state.anchorThreadKey).toBe(THREAD_C);
109 expect(state.selectedThreadKeys.size).toBe(0);
110110 });
111111 });
112112
......@@ -115,9 +115,9 @@ describe("threadSelectionStore", () => {
115115 useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED);
116116
117117 const state = useThreadSelectionStore.getState();
118 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
119 expect(state.selectedThreadIds.size).toBe(1);
120 expect(state.anchorThreadId).toBe(THREAD_C);
118 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
119 expect(state.selectedThreadKeys.size).toBe(1);
120 expect(state.anchorThreadKey).toBe(THREAD_C);
121121 });
122122
123123 it("selects range from anchor to target (forward)", () => {
......@@ -126,10 +126,10 @@ describe("threadSelectionStore", () => {
126126 store.rangeSelectTo(THREAD_D, ORDERED);
127127
128128 const state = useThreadSelectionStore.getState();
129 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
130 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
131 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);
132 expect(state.selectedThreadIds.size).toBe(3);
129 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
130 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
131 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
132 expect(state.selectedThreadKeys.size).toBe(3);
133133 });
134134
135135 it("selects range from anchor to target (backward)", () => {
......@@ -138,10 +138,10 @@ describe("threadSelectionStore", () => {
138138 store.rangeSelectTo(THREAD_B, ORDERED);
139139
140140 const state = useThreadSelectionStore.getState();
141 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
142 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
143 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);
144 expect(state.selectedThreadIds.size).toBe(3);
141 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
142 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
143 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
144 expect(state.selectedThreadKeys.size).toBe(3);
145145 });
146146
147147 it("keeps anchor stable across multiple range selects", () => {
......@@ -151,11 +151,11 @@ describe("threadSelectionStore", () => {
151151 store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B)
152152
153153 const state = useThreadSelectionStore.getState();
154 expect(state.anchorThreadId).toBe(THREAD_B);
155 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
156 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
157 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);
158 expect(state.selectedThreadIds.has(THREAD_E)).toBe(true);
154 expect(state.anchorThreadKey).toBe(THREAD_B);
155 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
156 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
157 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
158 expect(state.selectedThreadKeys.has(THREAD_E)).toBe(true);
159159 });
160160
161161 it("falls back to toggle when anchor is not in the ordered list", () => {
......@@ -166,8 +166,8 @@ describe("threadSelectionStore", () => {
166166
167167 const state = useThreadSelectionStore.getState();
168168 // Should have added C and reset anchor to C
169 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
170 expect(state.anchorThreadId).toBe(THREAD_C);
169 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
170 expect(state.anchorThreadKey).toBe(THREAD_C);
171171 });
172172
173173 it("falls back to toggle when target is not in the ordered list", () => {
......@@ -177,8 +177,8 @@ describe("threadSelectionStore", () => {
177177 store.rangeSelectTo(unknownThread, ORDERED);
178178
179179 const state = useThreadSelectionStore.getState();
180 expect(state.selectedThreadIds.has(unknownThread)).toBe(true);
181 expect(state.anchorThreadId).toBe(unknownThread);
180 expect(state.selectedThreadKeys.has(unknownThread)).toBe(true);
181 expect(state.anchorThreadKey).toBe(unknownThread);
182182 });
183183
184184 it("selects the single thread when anchor equals target", () => {
......@@ -187,8 +187,8 @@ describe("threadSelectionStore", () => {
187187 store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C
188188
189189 const state = useThreadSelectionStore.getState();
190 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
191 expect(state.selectedThreadIds.size).toBe(1);
190 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
191 expect(state.selectedThreadKeys.size).toBe(1);
192192 });
193193
194194 it("preserves previously selected threads outside the range", () => {
......@@ -200,11 +200,11 @@ describe("threadSelectionStore", () => {
200200 store.rangeSelectTo(THREAD_D, ORDERED);
201201
202202 const state = useThreadSelectionStore.getState();
203 expect(state.selectedThreadIds.has(THREAD_A)).toBe(true);
204 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
205 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
206 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);
207 expect(state.selectedThreadIds.size).toBe(4);
203 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true);
204 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
205 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
206 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
207 expect(state.selectedThreadKeys.size).toBe(4);
208208 });
209209 });
210210
......@@ -216,8 +216,8 @@ describe("threadSelectionStore", () => {
216216 store.clearSelection();
217217
218218 const state = useThreadSelectionStore.getState();
219 expect(state.selectedThreadIds.size).toBe(0);
220 expect(state.anchorThreadId).toBeNull();
219 expect(state.selectedThreadKeys.size).toBe(0);
220 expect(state.anchorThreadKey).toBeNull();
221221 });
222222
223223 it("is a no-op when already empty", () => {
......@@ -226,7 +226,7 @@ describe("threadSelectionStore", () => {
226226 const stateAfter = useThreadSelectionStore.getState();
227227
228228 // Should be referentially the same (no unnecessary re-render)
229 expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds);
229 expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys);
230230 });
231231 });
232232
......@@ -239,8 +239,8 @@ describe("threadSelectionStore", () => {
239239 store.removeFromSelection([THREAD_A, THREAD_C]);
240240
241241 const state = useThreadSelectionStore.getState();
242 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
243 expect(state.selectedThreadIds.size).toBe(1);
242 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
243 expect(state.selectedThreadKeys.size).toBe(1);
244244 });
245245
246246 it("clears anchor when the anchor thread is removed", () => {
......@@ -249,7 +249,7 @@ describe("threadSelectionStore", () => {
249249 store.toggleThread(THREAD_B); // anchor = B
250250 store.removeFromSelection([THREAD_B]);
251251
252 expect(useThreadSelectionStore.getState().anchorThreadId).toBeNull();
252 expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull();
253253 });
254254
255255 it("preserves anchor when the anchor thread is not removed", () => {
......@@ -258,7 +258,7 @@ describe("threadSelectionStore", () => {
258258 store.toggleThread(THREAD_B); // anchor = B
259259 store.removeFromSelection([THREAD_A]);
260260
261 expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B);
261 expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B);
262262 });
263263
264264 it("is a no-op when none of the specified threads are selected", () => {
......@@ -268,7 +268,7 @@ describe("threadSelectionStore", () => {
268268 store.removeFromSelection([THREAD_B, THREAD_C]);
269269 const stateAfter = useThreadSelectionStore.getState();
270270
271 expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds);
271 expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys);
272272 });
273273 });
274274
apps/web/src/threadSelectionStore.ts+51-53
......@@ -4,121 +4,119 @@
44 * Supports Cmd/Ctrl+Click (toggle individual), Shift+Click (range select),
55 * and bulk actions on the selected set.
66 */
7
8import type { ThreadId } from "@t3tools/contracts";
97import { create } from "zustand";
108
119export interface ThreadSelectionState {
12 /** Currently selected thread IDs. */
13 selectedThreadIds: ReadonlySet<ThreadId>;
14 /** The thread ID that anchors shift-click range selection. */
15 anchorThreadId: ThreadId | null;
10 /** Currently selected scoped thread keys. */
11 selectedThreadKeys: ReadonlySet<string>;
12 /** The scoped thread key that anchors shift-click range selection. */
13 anchorThreadKey: string | null;
1614}
1715
1816interface ThreadSelectionStore extends ThreadSelectionState {
19 /** Toggle a single thread in the selection (Cmd/Ctrl+Click). */
20 toggleThread: (threadId: ThreadId) => void;
17 /** Toggle a single scoped thread key in the selection (Cmd/Ctrl+Click). */
18 toggleThread: (threadKey: string) => void;
2119 /**
2220 * Select a range of threads (Shift+Click).
23 * Requires the ordered list of thread IDs within the same project
21 * Requires the ordered list of scoped thread keys within the same project
2422 * so the store can compute which threads fall between anchor and target.
2523 */
26 rangeSelectTo: (threadId: ThreadId, orderedThreadIds: readonly ThreadId[]) => void;
24 rangeSelectTo: (threadKey: string, orderedThreadKeys: readonly string[]) => void;
2725 /** Clear all selection state. */
2826 clearSelection: () => void;
29 /** Remove specific thread IDs from the selection (e.g. after deletion). */
30 removeFromSelection: (threadIds: readonly ThreadId[]) => void;
27 /** Remove specific scoped thread keys from the selection (e.g. after deletion). */
28 removeFromSelection: (threadKeys: readonly string[]) => void;
3129 /** 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;
3331 /** Check if any threads are selected. */
3432 hasSelection: () => boolean;
3533}
3634
37const EMPTY_SET = new Set<ThreadId>();
35const EMPTY_SET = new Set<string>();
3836
3937export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({
40 selectedThreadIds: EMPTY_SET,
41 anchorThreadId: null,
38 selectedThreadKeys: EMPTY_SET,
39 anchorThreadKey: null,
4240
43 toggleThread: (threadId) => {
41 toggleThread: (threadKey) => {
4442 set((state) => {
45 const next = new Set(state.selectedThreadIds);
46 if (next.has(threadId)) {
47 next.delete(threadId);
43 const next = new Set(state.selectedThreadKeys);
44 if (next.has(threadKey)) {
45 next.delete(threadKey);
4846 } else {
49 next.add(threadId);
47 next.add(threadKey);
5048 }
5149 return {
52 selectedThreadIds: next,
53 anchorThreadId: next.has(threadId) ? threadId : state.anchorThreadId,
50 selectedThreadKeys: next,
51 anchorThreadKey: next.has(threadKey) ? threadKey : state.anchorThreadKey,
5452 };
5553 });
5654 },
5755
58 rangeSelectTo: (threadId, orderedThreadIds) => {
56 rangeSelectTo: (threadKey, orderedThreadKeys) => {
5957 set((state) => {
60 const anchor = state.anchorThreadId;
58 const anchor = state.anchorThreadKey;
6159 if (anchor === null) {
6260 // No anchor yet — treat as a single toggle
63 const next = new Set(state.selectedThreadIds);
64 next.add(threadId);
65 return { selectedThreadIds: next, anchorThreadId: threadId };
61 const next = new Set(state.selectedThreadKeys);
62 next.add(threadKey);
63 return { selectedThreadKeys: next, anchorThreadKey: threadKey };
6664 }
6765
68 const anchorIndex = orderedThreadIds.indexOf(anchor);
69 const targetIndex = orderedThreadIds.indexOf(threadId);
66 const anchorIndex = orderedThreadKeys.indexOf(anchor);
67 const targetIndex = orderedThreadKeys.indexOf(threadKey);
7068 if (anchorIndex === -1 || targetIndex === -1) {
7169 // Anchor or target not in this list (different project?) — fallback to toggle
72 const next = new Set(state.selectedThreadIds);
73 next.add(threadId);
74 return { selectedThreadIds: next, anchorThreadId: threadId };
70 const next = new Set(state.selectedThreadKeys);
71 next.add(threadKey);
72 return { selectedThreadKeys: next, anchorThreadKey: threadKey };
7573 }
7674
7775 const start = Math.min(anchorIndex, targetIndex);
7876 const end = Math.max(anchorIndex, targetIndex);
79 const next = new Set(state.selectedThreadIds);
77 const next = new Set(state.selectedThreadKeys);
8078 for (let i = start; i <= end; i++) {
81 const id = orderedThreadIds[i];
82 if (id !== undefined) {
83 next.add(id);
79 const key = orderedThreadKeys[i];
80 if (key !== undefined) {
81 next.add(key);
8482 }
8583 }
8684 // Keep anchor stable so subsequent shift-clicks extend from the same point
87 return { selectedThreadIds: next, anchorThreadId: anchor };
85 return { selectedThreadKeys: next, anchorThreadKey: anchor };
8886 });
8987 },
9088
9189 clearSelection: () => {
9290 const state = get();
93 if (state.selectedThreadIds.size === 0 && state.anchorThreadId === null) return;
94 set({ selectedThreadIds: EMPTY_SET, anchorThreadId: null });
91 if (state.selectedThreadKeys.size === 0 && state.anchorThreadKey === null) return;
92 set({ selectedThreadKeys: EMPTY_SET, anchorThreadKey: null });
9593 },
9694
97 setAnchor: (threadId) => {
98 if (get().anchorThreadId === threadId) return;
99 set({ anchorThreadId: threadId });
95 setAnchor: (threadKey) => {
96 if (get().anchorThreadKey === threadKey) return;
97 set({ anchorThreadKey: threadKey });
10098 },
10199
102 removeFromSelection: (threadIds) => {
100 removeFromSelection: (threadKeys) => {
103101 set((state) => {
104 const toRemove = new Set(threadIds);
102 const toRemove = new Set(threadKeys);
105103 let changed = false;
106 const next = new Set<ThreadId>();
107 for (const id of state.selectedThreadIds) {
108 if (toRemove.has(id)) {
104 const next = new Set<string>();
105 for (const key of state.selectedThreadKeys) {
106 if (toRemove.has(key)) {
109107 changed = true;
110108 } else {
111 next.add(id);
109 next.add(key);
112110 }
113111 }
114112 if (!changed) return state;
115113 const newAnchor =
116 state.anchorThreadId !== null && toRemove.has(state.anchorThreadId)
114 state.anchorThreadKey !== null && toRemove.has(state.anchorThreadKey)
117115 ? null
118 : state.anchorThreadId;
119 return { selectedThreadIds: next, anchorThreadId: newAnchor };
116 : state.anchorThreadKey;
117 return { selectedThreadKeys: next, anchorThreadKey: newAnchor };
120118 });
121119 },
122120
123 hasSelection: () => get().selectedThreadIds.size > 0,
121 hasSelection: () => get().selectedThreadKeys.size > 0,
124122}));
apps/web/src/types.ts+7
......@@ -1,7 +1,9 @@
11import type {
2 EnvironmentId,
23 ModelSelection,
34 OrchestrationLatestTurn,
45 OrchestrationProposedPlanId,
6 RepositoryIdentity,
57 OrchestrationSessionStatus,
68 OrchestrationThreadActivity,
79 ProjectScript as ContractProjectScript,
......@@ -80,8 +82,10 @@ export interface TurnDiffSummary {
8082
8183export interface Project {
8284 id: ProjectId;
85 environmentId: EnvironmentId;
8386 name: string;
8487 cwd: string;
88 repositoryIdentity?: RepositoryIdentity | null;
8589 defaultModelSelection: ModelSelection | null;
8690 createdAt?: string | undefined;
8791 updatedAt?: string | undefined;
......@@ -90,6 +94,7 @@ export interface Project {
9094
9195export interface Thread {
9296 id: ThreadId;
97 environmentId: EnvironmentId;
9398 codexThreadId: string | null;
9499 projectId: ProjectId;
95100 title: string;
......@@ -113,6 +118,7 @@ export interface Thread {
113118
114119export interface ThreadShell {
115120 id: ThreadId;
121 environmentId: EnvironmentId;
116122 codexThreadId: string | null;
117123 projectId: ProjectId;
118124 title: string;
......@@ -134,6 +140,7 @@ export interface ThreadTurnState {
134140
135141export interface SidebarThreadSummary {
136142 id: ThreadId;
143 environmentId: EnvironmentId;
137144 projectId: ProjectId;
138145 title: string;
139146 interactionMode: ProviderInteractionMode;
apps/web/src/uiStateStore.test.ts+11-11
......@@ -74,9 +74,9 @@ describe("uiStateStore pure functions", () => {
7474 });
7575
7676 const next = syncProjects(initialState, [
77 { id: project1, cwd: "/tmp/project-1" },
78 { id: project2, cwd: "/tmp/project-2" },
79 { id: project3, cwd: "/tmp/project-3" },
77 { key: project1, cwd: "/tmp/project-1" },
78 { key: project2, cwd: "/tmp/project-2" },
79 { key: project3, cwd: "/tmp/project-3" },
8080 ]);
8181
8282 expect(next.projectOrder).toEqual([project2, project1, project3]);
......@@ -96,14 +96,14 @@ describe("uiStateStore pure functions", () => {
9696 projectOrder: [oldProject2, oldProject1],
9797 }),
9898 [
99 { id: oldProject1, cwd: "/tmp/project-1" },
100 { id: oldProject2, cwd: "/tmp/project-2" },
99 { key: oldProject1, cwd: "/tmp/project-1" },
100 { key: oldProject2, cwd: "/tmp/project-2" },
101101 ],
102102 );
103103
104104 const next = syncProjects(initialState, [
105 { id: oldProject1, cwd: "/tmp/project-1" },
106 { id: recreatedProject2, cwd: "/tmp/project-2" },
105 { key: oldProject1, cwd: "/tmp/project-1" },
106 { key: recreatedProject2, cwd: "/tmp/project-2" },
107107 ]);
108108
109109 expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]);
......@@ -119,10 +119,10 @@ describe("uiStateStore pure functions", () => {
119119 },
120120 projectOrder: [project1],
121121 }),
122 [{ id: project1, cwd: "/tmp/project-1" }],
122 [{ key: project1, cwd: "/tmp/project-1" }],
123123 );
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
127127 expect(next).not.toBe(initialState);
128128 expect(next.projectOrder).toEqual([project1]);
......@@ -139,7 +139,7 @@ describe("uiStateStore pure functions", () => {
139139 },
140140 });
141141
142 const next = syncThreads(initialState, [{ id: thread1 }]);
142 const next = syncThreads(initialState, [{ key: thread1 }]);
143143
144144 expect(next.threadLastVisitedAtById).toEqual({
145145 [thread1]: "2026-02-25T12:35:00.000Z",
......@@ -152,7 +152,7 @@ describe("uiStateStore pure functions", () => {
152152
153153 const next = syncThreads(initialState, [
154154 {
155 id: thread1,
155 key: thread1,
156156 seedVisitedAt: "2026-02-25T12:35:00.000Z",
157157 },
158158 ]);
apps/web/src/uiStateStore.ts+30-35
......@@ -1,5 +1,4 @@
11import { Debouncer } from "@tanstack/react-pacer";
2import { type ProjectId, type ThreadId } from "@t3tools/contracts";
32import { create } from "zustand";
43
54const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
......@@ -23,7 +22,7 @@ interface PersistedUiState {
2322
2423export interface UiProjectState {
2524 projectExpandedById: Record<string, boolean>;
26 projectOrder: ProjectId[];
25 projectOrder: string[];
2726}
2827
2928export interface UiThreadState {
......@@ -33,12 +32,12 @@ export interface UiThreadState {
3332export interface UiState extends UiProjectState, UiThreadState {}
3433
3534export interface SyncProjectInput {
36 id: ProjectId;
35 key: string;
3736 cwd: string;
3837}
3938
4039export interface SyncThreadInput {
41 id: ThreadId;
40 key: string;
4241 seedVisitedAt?: string | undefined;
4342}
4443
......@@ -50,7 +49,7 @@ const initialState: UiState = {
5049
5150const persistedExpandedProjectCwds = new Set<string>();
5251const persistedProjectOrderCwds: string[] = [];
53const currentProjectCwdById = new Map<ProjectId, string>();
52const currentProjectCwdById = new Map<string, string>();
5453let legacyKeysCleanedUp = false;
5554
5655function readPersistedState(): UiState {
......@@ -100,7 +99,7 @@ function persistState(state: UiState): void {
10099 const expandedProjectCwds = Object.entries(state.projectExpandedById)
101100 .filter(([, expanded]) => expanded)
102101 .flatMap(([projectId]) => {
103 const cwd = currentProjectCwdById.get(projectId as ProjectId);
102 const cwd = currentProjectCwdById.get(projectId);
104103 return cwd ? [cwd] : [];
105104 });
106105 const projectOrderCwds = state.projectOrder.flatMap((projectId) => {
......@@ -141,7 +140,7 @@ function recordsEqual<T>(left: Record<string, T>, right: Record<string, T>): boo
141140 return true;
142141}
143142
144function projectOrdersEqual(left: readonly ProjectId[], right: readonly ProjectId[]): boolean {
143function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean {
145144 return (
146145 left.length === right.length && left.every((projectId, index) => projectId === right[index])
147146 );
......@@ -154,11 +153,11 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
154153 );
155154 currentProjectCwdById.clear();
156155 for (const project of projects) {
157 currentProjectCwdById.set(project.id, project.cwd);
156 currentProjectCwdById.set(project.key, project.cwd);
158157 }
159158 const cwdMappingChanged =
160159 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
163162 const nextExpandedById: Record<string, boolean> = {};
164163 const previousExpandedById = state.projectExpandedById;
......@@ -168,14 +167,14 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
168167 const mappedProjects = projects.map((project, index) => {
169168 const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd);
170169 const expanded =
171 previousExpandedById[project.id] ??
170 previousExpandedById[project.key] ??
172171 (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ??
173172 (persistedExpandedProjectCwds.size > 0
174173 ? persistedExpandedProjectCwds.has(project.cwd)
175174 : true);
176 nextExpandedById[project.id] = expanded;
175 nextExpandedById[project.key] = expanded;
177176 return {
178 id: project.id,
177 id: project.key,
179178 cwd: project.cwd,
180179 incomingIndex: index,
181180 };
......@@ -187,8 +186,8 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
187186 const nextProjectIdByCwd = new Map(
188187 mappedProjects.map((project) => [project.cwd, project.id] as const),
189188 );
190 const usedProjectIds = new Set<ProjectId>();
191 const orderedProjectIds: ProjectId[] = [];
189 const usedProjectIds = new Set<string>();
190 const orderedProjectIds: string[] = [];
192191
193192 for (const projectId of state.projectOrder) {
194193 const matchedProjectId =
......@@ -246,19 +245,19 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
246245}
247246
248247export 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));
250249 const nextThreadLastVisitedAtById = Object.fromEntries(
251250 Object.entries(state.threadLastVisitedAtById).filter(([threadId]) =>
252 retainedThreadIds.has(threadId as ThreadId),
251 retainedThreadIds.has(threadId),
253252 ),
254253 );
255254 for (const thread of threads) {
256255 if (
257 nextThreadLastVisitedAtById[thread.id] === undefined &&
256 nextThreadLastVisitedAtById[thread.key] === undefined &&
258257 thread.seedVisitedAt !== undefined &&
259258 thread.seedVisitedAt.length > 0
260259 ) {
261 nextThreadLastVisitedAtById[thread.id] = thread.seedVisitedAt;
260 nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt;
262261 }
263262 }
264263 if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) {
......@@ -270,7 +269,7 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[])
270269 };
271270}
272271
273export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt?: string): UiState {
272export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState {
274273 const at = visitedAt ?? new Date().toISOString();
275274 const visitedAtMs = Date.parse(at);
276275 const previousVisitedAt = state.threadLastVisitedAtById[threadId];
......@@ -293,7 +292,7 @@ export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt?
293292
294293export function markThreadUnread(
295294 state: UiState,
296 threadId: ThreadId,
295 threadId: string,
297296 latestTurnCompletedAt: string | null | undefined,
298297): UiState {
299298 if (!latestTurnCompletedAt) {
......@@ -316,7 +315,7 @@ export function markThreadUnread(
316315 };
317316}
318317
319export function clearThreadUi(state: UiState, threadId: ThreadId): UiState {
318export function clearThreadUi(state: UiState, threadId: string): UiState {
320319 if (!(threadId in state.threadLastVisitedAtById)) {
321320 return state;
322321 }
......@@ -328,7 +327,7 @@ export function clearThreadUi(state: UiState, threadId: ThreadId): UiState {
328327 };
329328}
330329
331export function toggleProject(state: UiState, projectId: ProjectId): UiState {
330export function toggleProject(state: UiState, projectId: string): UiState {
332331 const expanded = state.projectExpandedById[projectId] ?? true;
333332 return {
334333 ...state,
......@@ -339,11 +338,7 @@ export function toggleProject(state: UiState, projectId: ProjectId): UiState {
339338 };
340339}
341340
342export function setProjectExpanded(
343 state: UiState,
344 projectId: ProjectId,
345 expanded: boolean,
346): UiState {
341export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState {
347342 if ((state.projectExpandedById[projectId] ?? true) === expanded) {
348343 return state;
349344 }
......@@ -358,8 +353,8 @@ export function setProjectExpanded(
358353
359354export function reorderProjects(
360355 state: UiState,
361 draggedProjectId: ProjectId,
362 targetProjectId: ProjectId,
356 draggedProjectId: string,
357 targetProjectId: string,
363358): UiState {
364359 if (draggedProjectId === targetProjectId) {
365360 return state;
......@@ -384,12 +379,12 @@ export function reorderProjects(
384379interface UiStateStore extends UiState {
385380 syncProjects: (projects: readonly SyncProjectInput[]) => void;
386381 syncThreads: (threads: readonly SyncThreadInput[]) => void;
387 markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void;
388 markThreadUnread: (threadId: ThreadId, latestTurnCompletedAt: string | null | undefined) => void;
389 clearThreadUi: (threadId: ThreadId) => void;
390 toggleProject: (projectId: ProjectId) => void;
391 setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void;
392 reorderProjects: (draggedProjectId: ProjectId, targetProjectId: ProjectId) => void;
382 markThreadVisited: (threadId: string, visitedAt?: string) => void;
383 markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
384 clearThreadUi: (threadId: string) => void;
385 toggleProject: (projectId: string) => void;
386 setProjectExpanded: (projectId: string, expanded: boolean) => void;
387 reorderProjects: (draggedProjectId: string, targetProjectId: string) => void;
393388}
394389
395390export const useUiStateStore = create<UiStateStore>((set) => ({
apps/web/src/vite-env.d.ts+2-2
......@@ -1,6 +1,6 @@
11/// <reference types="vite/client" />
22
3import type { NativeApi, DesktopBridge } from "@t3tools/contracts";
3import type { DesktopBridge, LocalApi } from "@t3tools/contracts";
44
55interface ImportMetaEnv {
66 readonly APP_VERSION: string;
......@@ -12,7 +12,7 @@ interface ImportMeta {
1212
1313declare global {
1414 interface Window {
15 nativeApi?: NativeApi;
15 nativeApi?: LocalApi;
1616 desktopBridge?: DesktopBridge;
1717 }
1818}
apps/web/src/worktreeCleanup.test.ts+4-1
......@@ -1,12 +1,15 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
44import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
55import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";
66
7const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
8
79function makeThread(overrides: Partial<Thread> = {}): Thread {
810 return {
911 id: ThreadId.makeUnsafe("thread-1"),
12 environmentId: localEnvironmentId,
1013 codexThreadId: null,
1114 projectId: ProjectId.makeUnsafe("project-1"),
1215 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 {
33 GitStatusRemoteResult,
44 GitStatusStreamEvent,
55} 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";
925import { type WsTransport } from "./wsTransport";
1026
1127const baseLocalStatus: GitStatusLocalResult = {
......@@ -25,6 +41,10 @@ const baseRemoteStatus: GitStatusRemoteResult = {
2541};
2642
2743describe("wsRpcClient", () => {
44 afterEach(async () => {
45 await __resetWsRpcClientForTests();
46 });
47
2848 it("reduces git status stream events into flat status snapshots", () => {
2949 const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => {
3050 for (const event of [
......@@ -91,4 +111,20 @@ describe("wsRpcClient", () => {
91111 ],
92112 ]);
93113 });
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 });
94130});
apps/web/src/wsRpcClient.ts+153-12
......@@ -4,14 +4,20 @@ import {
44 type GitRunStackedActionResult,
55 type GitStatusResult,
66 type GitStatusStreamEvent,
7 type NativeApi,
7 type LocalApi,
88 ORCHESTRATION_WS_METHODS,
9 type EnvironmentId,
910 type ServerSettingsPatch,
1011 WS_METHODS,
1112} from "@t3tools/contracts";
13import { getKnownEnvironmentBaseUrl, type KnownEnvironment } from "@t3tools/client-runtime";
1214import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
1315import { Effect, Stream } from "effect";
1416
17import {
18 getPrimaryKnownEnvironment,
19 resolvePrimaryEnvironmentBootstrapUrl,
20} from "./environmentBootstrap";
1521import { type WsRpcProtocolClient } from "./rpc/protocol";
1622import { resetWsReconnectBackoff } from "./rpc/wsConnectionState";
1723import { WsTransport } from "./wsTransport";
......@@ -61,9 +67,9 @@ export interface WsRpcClient {
6167 };
6268 readonly shell: {
6369 readonly openInEditor: (input: {
64 readonly cwd: Parameters<NativeApi["shell"]["openInEditor"]>[0];
65 readonly editor: Parameters<NativeApi["shell"]["openInEditor"]>[1];
66 }) => ReturnType<NativeApi["shell"]["openInEditor"]>;
70 readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0];
71 readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1];
72 }) => ReturnType<LocalApi["shell"]["openInEditor"]>;
6773 };
6874 readonly git: {
6975 readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>;
......@@ -109,22 +115,157 @@ export interface WsRpcClient {
109115 };
110116}
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
114197export function getWsRpcClient(): WsRpcClient {
115 if (sharedWsRpcClient) {
116 return sharedWsRpcClient;
198 return getPrimaryWsRpcClientEntry().client;
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}.`);
117253 }
118 sharedWsRpcClient = createWsRpcClient();
119 return sharedWsRpcClient;
254 return entry.client;
120255}
121256
122257export async function __resetWsRpcClientForTests() {
123 await sharedWsRpcClient?.dispose();
124 sharedWsRpcClient = null;
258 for (const entry of wsRpcClientEntriesByKey.values()) {
259 await entry.client.dispose();
260 }
261 wsRpcClientEntriesByKey.clear();
262 wsRpcClientKeyByEnvironmentId.clear();
263 wsRpcClientRegistryListeners.clear();
125264}
126265
127export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {
266export function createWsRpcClient(
267 transport = new WsTransport(resolvePrimaryEnvironmentBootstrapUrl()),
268): WsRpcClient {
128269 return {
129270 dispose: () => transport.dispose(),
130271 reconnect: async () => {
apps/web/src/wsTransport.test.ts+21
......@@ -436,6 +436,13 @@ describe("WsTransport", () => {
436436 sequence: 1,
437437 type: "welcome",
438438 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 },
439446 cwd: "/tmp/workspace",
440447 projectName: "workspace",
441448 },
......@@ -489,6 +496,13 @@ describe("WsTransport", () => {
489496 sequence: 1,
490497 type: "welcome",
491498 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 },
492506 cwd: "/tmp/one",
493507 projectName: "one",
494508 },
......@@ -532,6 +546,13 @@ describe("WsTransport", () => {
532546 sequence: 2,
533547 type: "welcome",
534548 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 },
535556 cwd: "/tmp/two",
536557 projectName: "two",
537558 },
bun.lock+15
......@@ -82,6 +82,7 @@
8282 "@formkit/auto-animate": "^0.9.0",
8383 "@lexical/react": "^0.41.0",
8484 "@pierre/diffs": "^1.1.0-beta.16",
85 "@t3tools/client-runtime": "workspace:*",
8586 "@t3tools/contracts": "workspace:*",
8687 "@t3tools/shared": "workspace:*",
8788 "@tanstack/react-pacer": "^0.19.4",
......@@ -121,6 +122,18 @@
121122 "vitest-browser-react": "^2.0.5",
122123 },
123124 },
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 },
124137 "packages/contracts": {
125138 "name": "@t3tools/contracts",
126139 "version": "0.0.15",
......@@ -659,6 +672,8 @@
659672
660673 "@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
662677 "@t3tools/contracts": ["@t3tools/contracts@workspace:packages/contracts"],
663678
664679 "@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");
1919export type ThreadId = typeof ThreadId.Type;
2020export const ProjectId = makeEntityId("ProjectId");
2121export type ProjectId = typeof ProjectId.Type;
22export const EnvironmentId = makeEntityId("EnvironmentId");
23export type EnvironmentId = typeof EnvironmentId.Type;
2224export const CommandId = makeEntityId("CommandId");
2325export type CommandId = typeof CommandId.Type;
2426export 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 @@
11export * from "./baseSchemas";
2export * from "./environment";
23export * from "./ipc";
34export * from "./terminal";
45export * from "./provider";
packages/contracts/src/ipc.ts+46-20
......@@ -105,8 +105,14 @@ export interface DesktopUpdateCheckResult {
105105 state: DesktopUpdateState;
106106}
107107
108export interface DesktopEnvironmentBootstrap {
109 label: string;
110 wsUrl: string | null;
111}
112
108113export interface DesktopBridge {
109114 getWsUrl: () => string | null;
115 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
110116 pickFolder: () => Promise<string | null>;
111117 confirm: (message: string) => Promise<boolean>;
112118 setTheme: (theme: DesktopTheme) => Promise<void>;
......@@ -123,11 +129,50 @@ export interface DesktopBridge {
123129 onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;
124130}
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 {
127143 dialogs: {
128144 pickFolder: () => Promise<string | null>;
129145 confirm: (message: string) => Promise<boolean>;
130146 };
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 {
131176 terminal: {
132177 open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>;
133178 write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>;
......@@ -141,12 +186,7 @@ export interface NativeApi {
141186 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
142187 writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;
143188 };
144 shell: {
145 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
146 openExternal: (url: string) => Promise<void>;
147 };
148189 git: {
149 // Existing branch/worktree API
150190 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;
151191 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;
152192 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;
......@@ -157,7 +197,6 @@ export interface NativeApi {
157197 preparePullRequestThread: (
158198 input: GitPreparePullRequestThreadInput,
159199 ) => Promise<GitPreparePullRequestThreadResult>;
160 // Stacked action API
161200 pull: (input: GitPullInput) => Promise<GitPullResult>;
162201 refreshStatus: (input: GitStatusInput) => Promise<GitStatusResult>;
163202 onStatus: (
......@@ -168,19 +207,6 @@ export interface NativeApi {
168207 },
169208 ) => () => void;
170209 };
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 };
184210 orchestration: {
185211 getSnapshot: () => Promise<OrchestrationReadModel>;
186212 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
packages/contracts/src/orchestration.ts+4
......@@ -1,5 +1,6 @@
11import { Option, Schema, SchemaIssue, Struct } from "effect";
22import { ClaudeModelOptions, CodexModelOptions } from "./model";
3import { RepositoryIdentity } from "./environment";
34import {
45 ApprovalRequestId,
56 CheckpointRef,
......@@ -141,6 +142,7 @@ export const OrchestrationProject = Schema.Struct({
141142 id: ProjectId,
142143 title: TrimmedNonEmptyString,
143144 workspaceRoot: TrimmedNonEmptyString,
145 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
144146 defaultModelSelection: Schema.NullOr(ModelSelection),
145147 scripts: Schema.Array(ProjectScript),
146148 createdAt: IsoDateTime,
......@@ -647,6 +649,7 @@ export const ProjectCreatedPayload = Schema.Struct({
647649 projectId: ProjectId,
648650 title: TrimmedNonEmptyString,
649651 workspaceRoot: TrimmedNonEmptyString,
652 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
650653 defaultModelSelection: Schema.NullOr(ModelSelection),
651654 scripts: Schema.Array(ProjectScript),
652655 createdAt: IsoDateTime,
......@@ -657,6 +660,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
657660 projectId: ProjectId,
658661 title: Schema.optional(TrimmedNonEmptyString),
659662 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
663 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
660664 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
661665 scripts: Schema.optional(Schema.Array(ProjectScript)),
662666 updatedAt: IsoDateTime,
packages/contracts/src/server.ts+4
......@@ -1,4 +1,5 @@
11import { Schema } from "effect";
2import { ExecutionEnvironmentDescriptor } from "./environment";
23import {
34 IsoDateTime,
45 NonNegativeInt,
......@@ -83,6 +84,7 @@ export const ServerObservability = Schema.Struct({
8384export type ServerObservability = typeof ServerObservability.Type;
8485
8586export const ServerConfig = Schema.Struct({
87 environment: ExecutionEnvironmentDescriptor,
8688 cwd: TrimmedNonEmptyString,
8789 keybindingsConfigPath: TrimmedNonEmptyString,
8890 keybindings: ResolvedKeybindingsConfig,
......@@ -167,10 +169,12 @@ export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;
167169
168170export const ServerLifecycleReadyPayload = Schema.Struct({
169171 at: IsoDateTime,
172 environment: ExecutionEnvironmentDescriptor,
170173});
171174export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type;
172175
173176export const ServerLifecycleWelcomePayload = Schema.Struct({
177 environment: ExecutionEnvironmentDescriptor,
174178 cwd: TrimmedNonEmptyString,
175179 projectName: TrimmedNonEmptyString,
176180 bootstrapProjectId: Schema.optional(ProjectId),
packages/shared/src/git.test.ts+48-1
......@@ -1,7 +1,54 @@
11import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts";
22import { 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
653describe("applyGitStatusStreamEvent", () => {
754 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 {
8080 return branchName.slice(firstSeparatorIndex + 1);
8181}
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
83133function deriveLocalBranchNameCandidatesFromRemoteRef(
84134 branchName: string,
85135 remoteName?: string,
scripts/release-smoke.ts+1
......@@ -13,6 +13,7 @@ const workspaceFiles = [
1313 "apps/desktop/package.json",
1414 "apps/web/package.json",
1515 "apps/marketing/package.json",
16 "packages/client-runtime/package.json",
1617 "packages/contracts/package.json",
1718 "packages/shared/package.json",
1819 "scripts/package.json",