diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3d61571df9..de327d0ff8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -60,6 +60,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SCHEME = "t3"; @@ -1172,6 +1173,14 @@ function registerIpcHandlers(): void { event.returnValue = backendWsUrl; }); + ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { + event.returnValue = { + label: "Local environment", + wsUrl: backendWsUrl || null, + } as const; + }); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3d59db1714..bd678844ef 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => { const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); return typeof result === "string" ? result : null; }, + getLocalEnvironmentBootstrap: () => { + const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + if (typeof result !== "object" || result === null) { + return null; + } + return result as ReturnType; + }, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 87c81f08c8..dfadff9e70 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -45,6 +45,7 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; +import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; @@ -338,6 +339,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(orchestrationReactorLayer), Layer.provide(persistenceLayer), + Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 9ceea4c13c..887eb11c4f 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -30,6 +30,7 @@ export interface ServerDerivedPaths { readonly providerEventLogPath: string; readonly terminalLogsDir: string; readonly anonymousIdPath: string; + readonly environmentIdPath: string; } /** @@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* ( providerEventLogPath: join(providerLogsDir, "events.log"), terminalLogsDir: join(logsDir, "terminals"), anonymousIdPath: join(stateDir, "anonymous-id"), + environmentIdPath: join(stateDir, "environment-id"), }; }); diff --git a/apps/server/src/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/Layers/ServerEnvironment.test.ts new file mode 100644 index 0000000000..a9668760f2 --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironment.test.ts @@ -0,0 +1,124 @@ +import * as nodePath from "node:path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; + +import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts"; +import { ServerEnvironment } from "../Services/ServerEnvironment.ts"; +import { ServerEnvironmentLive } from "./ServerEnvironment.ts"; + +const makeServerEnvironmentLayer = (baseDir: string) => + ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + +const makeServerConfig = Effect.fn(function* (baseDir: string) { + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + + return { + ...derivedPaths, + logLevel: "Error", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + cwd: process.cwd(), + baseDir, + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + port: 0, + host: undefined, + authToken: undefined, + staticDir: undefined, + devUrl: undefined, + noBrowser: false, + } satisfies ServerConfigShape; +}); + +it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect("persists the environment id across service restarts", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-test-", + }); + + const first = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + const second = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + + expect(first.environmentId).toBe(second.environmentId); + expect(second.capabilities.repositoryIdentity).toBe(true); + }), + ); + + it.effect("fails instead of overwriting a persisted id when reading the file errors", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-read-error-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + const environmentIdPath = serverConfig.environmentIdPath; + yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true }); + yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); + const writeAttempts: string[] = []; + const failingFileSystemLayer = FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === environmentIdPath), + readFileString: (path) => + path === environmentIdPath + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + description: "permission denied", + pathOrDescriptor: path, + }), + ) + : Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + description: "not found", + pathOrDescriptor: path, + }), + ), + writeFileString: (path) => { + writeAttempts.push(path); + return Effect.void; + }, + }); + + const exit = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironmentLive.pipe( + Layer.provide( + Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer), + ), + ), + ), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(writeAttempts).toEqual([]); + expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( + "persisted-environment-id\n", + ); + }), + ); +}); diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts new file mode 100644 index 0000000000..fd58425dee --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -0,0 +1,94 @@ +import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { Effect, FileSystem, Layer, Path, Random } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; +import { version } from "../../../package.json" with { type: "json" }; + +function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { + switch (process.platform) { + case "darwin": + return "darwin"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + return "unknown"; + } +} + +function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { + switch (process.arch) { + case "arm64": + return "arm64"; + case "x64": + return "x64"; + default: + return "other"; + } +} + +export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + + const readPersistedEnvironmentId = Effect.gen(function* () { + const exists = yield* fileSystem + .exists(serverConfig.environmentIdPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return null; + } + + const raw = yield* fileSystem + .readFileString(serverConfig.environmentIdPath) + .pipe(Effect.map((value) => value.trim())); + + return raw.length > 0 ? raw : null; + }); + + const persistEnvironmentId = (value: string) => + fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); + + const environmentIdRaw = yield* Effect.gen(function* () { + const persisted = yield* readPersistedEnvironmentId; + if (persisted) { + return persisted; + } + + const generated = yield* Random.nextUUIDv4; + yield* persistEnvironmentId(generated); + return generated; + }); + + const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw); + const cwdBaseName = path.basename(serverConfig.cwd).trim(); + const label = + serverConfig.mode === "desktop" + ? "Local environment" + : cwdBaseName.length > 0 + ? cwdBaseName + : "T3 environment"; + + const descriptor: ExecutionEnvironmentDescriptor = { + environmentId, + label, + platform: { + os: platformOs(), + arch: platformArch(), + }, + serverVersion: version, + capabilities: { + repositoryIdentity: true, + }, + }; + + return { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + } satisfies ServerEnvironmentShape; +}); + +export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()); diff --git a/apps/server/src/environment/Services/ServerEnvironment.ts b/apps/server/src/environment/Services/ServerEnvironment.ts new file mode 100644 index 0000000000..9cf432ca72 --- /dev/null +++ b/apps/server/src/environment/Services/ServerEnvironment.ts @@ -0,0 +1,13 @@ +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ServerEnvironmentShape { + readonly getEnvironmentId: Effect.Effect; + readonly getDescriptor: Effect.Effect; +} + +export class ServerEnvironment extends ServiceMap.Service< + ServerEnvironment, + ServerEnvironmentShape +>()("t3/environment/Services/ServerEnvironment") {} diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 005bdb5bc6..38cbd13014 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -854,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), - 12_000, + 20_000, ); it.effect( @@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ), ).toBe(false); }), - 12_000, + 20_000, ); it.effect("status returns merged PR state when latest PR was merged", () => @@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { false, ); }), - 12_000, + 20_000, ); it.effect( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 72adb175f9..d97d0f71fa 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -256,6 +257,7 @@ describe("CheckpointReactor", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 5a0a6113f0..77b12e86ae 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -19,6 +19,7 @@ import { OrchestrationEventStore, type OrchestrationEventStoreShape, } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -45,6 +46,7 @@ async function createOrchestrationSystem() { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -623,6 +625,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ), ); @@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1850745469..6835f79d01 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -20,6 +20,7 @@ import { SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { ORCHESTRATION_PROJECTOR_NAMES, @@ -1846,6 +1847,7 @@ const engineLayer = it.layer( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c038bc9d2c..0a9d90107f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -4,6 +4,7 @@ import { Effect, Layer } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); const projectionSnapshotLayer = it.layer( - OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), ); projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { @@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + repositoryIdentity: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index da7c695674..b0f883f940 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -38,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, @@ -163,6 +164,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + const repositoryIdentityResolutionConcurrency = 4; const listProjectRows = SqlSchema.findAll({ Request: Schema.Void, @@ -436,269 +439,283 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => sql .withTransaction( - Effect.gen(function* () { - const [ - projectRows, - threadRows, - messageRows, - proposedPlanRows, - activityRows, - sessionRows, - checkpointRows, - latestTurnRows, - stateRows, - ] = yield* Effect.all([ - listProjectRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listProjects:query", - "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", - ), - ), - ), - listThreadRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreads:query", - "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", - ), + Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", ), ), - listThreadMessageRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", - ), + ), + listThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", ), ), - listThreadProposedPlanRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", - ), + ), + listThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", ), ), - listThreadActivityRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", - ), + ), + listThreadProposedPlanRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", ), ), - listThreadSessionRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", - ), + ), + listThreadActivityRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", ), ), - listCheckpointRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", - "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", - ), + ), + listThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", ), ), - listLatestTurnRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", - "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", - ), + ), + listCheckpointRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", ), ), - listProjectionStateRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", - "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", - ), + ), + listLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", ), ), - ]); - - const messagesByThread = new Map>(); - const proposedPlansByThread = new Map>(); - const activitiesByThread = new Map>(); - const checkpointsByThread = new Map>(); - const sessionsByThread = new Map(); - const latestTurnByThread = new Map(); - - let updatedAt: string | null = null; - - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - - for (const row of messageRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - const threadMessages = messagesByThread.get(row.threadId) ?? []; - threadMessages.push({ - id: row.messageId, - role: row.role, - text: row.text, - ...(row.attachments !== null ? { attachments: row.attachments } : {}), - turnId: row.turnId, - streaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); - messagesByThread.set(row.threadId, threadMessages); - } - - for (const row of proposedPlanRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; - threadProposedPlans.push({ - id: row.planId, - turnId: row.turnId, - planMarkdown: row.planMarkdown, - implementedAt: row.implementedAt, - implementationThreadId: row.implementationThreadId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); - proposedPlansByThread.set(row.threadId, threadProposedPlans); - } - - for (const row of activityRows) { - updatedAt = maxIso(updatedAt, row.createdAt); - const threadActivities = activitiesByThread.get(row.threadId) ?? []; - threadActivities.push({ - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - }); - activitiesByThread.set(row.threadId, threadActivities); - } - - for (const row of checkpointRows) { - updatedAt = maxIso(updatedAt, row.completedAt); - const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; - threadCheckpoints.push({ - turnId: row.turnId, - checkpointTurnCount: row.checkpointTurnCount, - checkpointRef: row.checkpointRef, - status: row.status, - files: row.files, - assistantMessageId: row.assistantMessageId, - completedAt: row.completedAt, - }); - checkpointsByThread.set(row.threadId, threadCheckpoints); - } - - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); - } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); - } - if (latestTurnByThread.has(row.threadId)) { - continue; - } - latestTurnByThread.set(row.threadId, { - turnId: row.turnId, - state: - row.state === "error" - ? "error" - : row.state === "interrupted" - ? "interrupted" - : row.state === "completed" - ? "completed" - : "running", - requestedAt: row.requestedAt, - startedAt: row.startedAt, - completedAt: row.completedAt, - assistantMessageId: row.assistantMessageId, - ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null - ? { - sourceProposedPlan: { - threadId: row.sourceProposedPlanThreadId, - planId: row.sourceProposedPlanId, - }, - } - : {}), - }); - } - - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - sessionsByThread.set(row.threadId, { - threadId: row.threadId, - status: row.status, - providerName: row.providerName, - runtimeMode: row.runtimeMode, - activeTurnId: row.activeTurnId, - lastError: row.lastError, - updatedAt: row.updatedAt, - }); - } - - const projects: ReadonlyArray = projectRows.map((row) => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - })); - - const threads: ReadonlyArray = threadRows.map((row) => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], - checkpoints: checkpointsByThread.get(row.threadId) ?? [], - session: sessionsByThread.get(row.threadId) ?? null, - })); - - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects, - threads, - updatedAt: updatedAt ?? new Date(0).toISOString(), - }; - - return yield* decodeReadModel(snapshot).pipe( + ), + listProjectionStateRows(undefined).pipe( Effect.mapError( - toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", + ), ), - ); - }), + ), + ]), ) .pipe( + Effect.flatMap( + ([ + projectRows, + threadRows, + messageRows, + proposedPlanRows, + activityRows, + sessionRows, + checkpointRows, + latestTurnRows, + stateRows, + ]) => + Effect.gen(function* () { + const messagesByThread = new Map>(); + const proposedPlansByThread = new Map>(); + const activitiesByThread = new Map>(); + const checkpointsByThread = new Map>(); + const sessionsByThread = new Map(); + const latestTurnByThread = new Map(); + + let updatedAt: string | null = null; + + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + for (const row of messageRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadMessages = messagesByThread.get(row.threadId) ?? []; + threadMessages.push({ + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + messagesByThread.set(row.threadId, threadMessages); + } + + for (const row of proposedPlanRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; + threadProposedPlans.push({ + id: row.planId, + turnId: row.turnId, + planMarkdown: row.planMarkdown, + implementedAt: row.implementedAt, + implementationThreadId: row.implementationThreadId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + proposedPlansByThread.set(row.threadId, threadProposedPlans); + } + + for (const row of activityRows) { + updatedAt = maxIso(updatedAt, row.createdAt); + const threadActivities = activitiesByThread.get(row.threadId) ?? []; + threadActivities.push({ + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + }); + activitiesByThread.set(row.threadId, threadActivities); + } + + for (const row of checkpointRows) { + updatedAt = maxIso(updatedAt, row.completedAt); + const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; + threadCheckpoints.push({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + }); + checkpointsByThread.set(row.threadId, threadCheckpoints); + } + + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + if (latestTurnByThread.has(row.threadId)) { + continue; + } + latestTurnByThread.set(row.threadId, { + turnId: row.turnId, + state: + row.state === "error" + ? "error" + : row.state === "interrupted" + ? "interrupted" + : row.state === "completed" + ? "completed" + : "running", + requestedAt: row.requestedAt, + startedAt: row.startedAt, + completedAt: row.completedAt, + assistantMessageId: row.assistantMessageId, + ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: row.sourceProposedPlanThreadId, + planId: row.sourceProposedPlanId, + }, + } + : {}), + }); + } + + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + sessionsByThread.set(row.threadId, { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + runtimeMode: row.runtimeMode, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }); + } + + const repositoryIdentities = new Map( + yield* Effect.forEach( + projectRows, + (row) => + repositoryIdentityResolver + .resolve(row.workspaceRoot) + .pipe(Effect.map((identity) => [row.projectId, identity] as const)), + { concurrency: repositoryIdentityResolutionConcurrency }, + ), + ); + + const projects: ReadonlyArray = projectRows.map((row) => ({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })); + + const threads: ReadonlyArray = threadRows.map((row) => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + })); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects, + threads, + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; + + return yield* decodeReadModel(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), + ), + ); + }), + ), Effect.mapError((error) => { if (isPersistenceError(error)) { return error; @@ -732,19 +749,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", ), ), - Effect.map( - Option.map( - (row): OrchestrationProject => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - }), - ), + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : repositoryIdentityResolver.resolve(option.value.workspaceRoot).pipe( + Effect.map((repositoryIdentity) => + Option.some({ + id: option.value.projectId, + title: option.value.title, + workspaceRoot: option.value.workspaceRoot, + repositoryIdentity, + defaultModelSelection: option.value.defaultModelSelection, + scripts: option.value.scripts, + createdAt: option.value.createdAt, + updatedAt: option.value.updatedAt, + deletedAt: option.value.deletedAt, + } satisfies OrchestrationProject), + ), + ), ), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index fe6cb9caf5..f41a596ceb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -28,6 +28,7 @@ import { } from "../../provider/Services/ProviderService.ts"; import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); const layer = ProviderCommandReactorLive.pipe( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 85f4d966e3..30f43365d9 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -29,6 +29,7 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); const layer = ProviderRuntimeIngestionLive.pipe( diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts new file mode 100644 index 0000000000..57f4464804 --- /dev/null +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -0,0 +1,193 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Duration, Effect, FileSystem, Layer } from "effect"; +import { TestClock } from "effect/testing"; + +import { runProcess } from "../../processRunner.ts"; +import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; +import { + makeRepositoryIdentityResolver, + RepositoryIdentityResolverLive, +} from "./RepositoryIdentityResolver.ts"; + +const git = (cwd: string, args: ReadonlyArray) => + Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); + +const makeRepositoryIdentityResolverTestLayer = (options: { + readonly positiveCacheTtl?: Duration.Input; + readonly negativeCacheTtl?: Duration.Input; +}) => + Layer.effect( + RepositoryIdentityResolver, + makeRepositoryIdentityResolver({ + cacheCapacity: 16, + ...options, + }), + ); + +it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); + expect(identity?.provider).toBe("github"); + expect(identity?.owner).toBe("t3tools"); + expect(identity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("returns null for non-git folders and repos without remotes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-non-git-", + }); + const gitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-no-remote-", + }); + + yield* git(gitDir, ["init"]); + + const resolver = yield* RepositoryIdentityResolver; + const nonGitIdentity = yield* resolver.resolve(nonGitDir); + const noRemoteIdentity = yield* resolver.resolve(gitDir); + + expect(nonGitIdentity).toBeNull(); + expect(noRemoteIdentity).toBeNull(); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("prefers upstream over origin when both remotes are configured", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-upstream-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); + yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.locator.remoteName).toBe("upstream"); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("uses the last remote path segment as the repository name for nested groups", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-nested-group-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code"); + expect(identity?.displayName).toBe("t3tools/platform/t3code"); + expect(identity?.owner).toBe("t3tools"); + expect(identity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect( + "refreshes cached null identities after the negative TTL when a remote is configured later", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-late-remote-test-", + }); + + yield* git(cwd, ["init"]); + + const resolver = yield* RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity).toBeNull(); + + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).toBeNull(); + + yield* TestClock.adjust(Duration.millis(120)); + + const refreshedIdentity = yield* resolver.resolve(cwd); + expect(refreshedIdentity).not.toBeNull(); + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(refreshedIdentity?.name).toBe("t3code"); + }).pipe( + Effect.provide( + Layer.merge( + TestClock.layer(), + makeRepositoryIdentityResolverTestLayer({ + negativeCacheTtl: Duration.millis(50), + positiveCacheTtl: Duration.seconds(1), + }), + ), + ), + ), + ); + + it.effect("refreshes cached identities after the positive TTL when a remote changes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-remote-change-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity).not.toBeNull(); + expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + + yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); + + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).not.toBeNull(); + expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + + yield* TestClock.adjust(Duration.millis(180)); + + const refreshedIdentity = yield* resolver.resolve(cwd); + expect(refreshedIdentity).not.toBeNull(); + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); + expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next"); + expect(refreshedIdentity?.name).toBe("t3code-next"); + }).pipe( + Effect.provide( + Layer.merge( + TestClock.layer(), + makeRepositoryIdentityResolverTestLayer({ + negativeCacheTtl: Duration.millis(50), + positiveCacheTtl: Duration.millis(100), + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts new file mode 100644 index 0000000000..4e33f5c162 --- /dev/null +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -0,0 +1,147 @@ +import type { RepositoryIdentity } from "@t3tools/contracts"; +import { Cache, Duration, Effect, Exit, Layer } from "effect"; +import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git"; + +import { runProcess } from "../../processRunner.ts"; +import { + RepositoryIdentityResolver, + type RepositoryIdentityResolverShape, +} from "../Services/RepositoryIdentityResolver.ts"; + +function parseRemoteFetchUrls(stdout: string): Map { + const remotes = new Map(); + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) continue; + const [, remoteName = "", remoteUrl = "", direction = ""] = match; + if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { + continue; + } + remotes.set(remoteName, remoteUrl); + } + return remotes; +} + +function pickPrimaryRemote( + remotes: ReadonlyMap, +): { readonly remoteName: string; readonly remoteUrl: string } | null { + for (const preferredRemoteName of ["upstream", "origin"] as const) { + const remoteUrl = remotes.get(preferredRemoteName); + if (remoteUrl) { + return { remoteName: preferredRemoteName, remoteUrl }; + } + } + + const [remoteName, remoteUrl] = + [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? []; + return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; +} + +function buildRepositoryIdentity(input: { + readonly remoteName: string; + readonly remoteUrl: string; +}): RepositoryIdentity { + const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); + const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); + const repositoryPath = canonicalKey.split("/").slice(1).join("/"); + const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); + const [owner] = repositoryPathSegments; + const repositoryName = repositoryPathSegments.at(-1); + + return { + canonicalKey, + locator: { + source: "git-remote", + remoteName: input.remoteName, + remoteUrl: input.remoteUrl, + }, + ...(repositoryPath ? { displayName: repositoryPath } : {}), + ...(hostingProvider ? { provider: hostingProvider.kind } : {}), + ...(owner ? { owner } : {}), + ...(repositoryName ? { name: repositoryName } : {}), + }; +} + +const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; +const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10); + +interface RepositoryIdentityResolverOptions { + readonly cacheCapacity?: number; + readonly positiveCacheTtl?: Duration.Input; + readonly negativeCacheTtl?: Duration.Input; +} + +async function resolveRepositoryIdentityCacheKey(cwd: string): Promise { + let cacheKey = cwd; + + try { + const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { + allowNonZeroExit: true, + }); + if (topLevelResult.code !== 0) { + return cacheKey; + } + + const candidate = topLevelResult.stdout.trim(); + if (candidate.length > 0) { + cacheKey = candidate; + } + } catch { + return cacheKey; + } + + return cacheKey; +} + +async function resolveRepositoryIdentityFromCacheKey( + cacheKey: string, +): Promise { + try { + const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], { + allowNonZeroExit: true, + }); + if (remoteResult.code !== 0) { + return null; + } + + const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); + return remote ? buildRepositoryIdentity(remote) : null; + } catch { + return null; + } +} + +export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( + function* (options: RepositoryIdentityResolverOptions = {}) { + const repositoryIdentityCache = yield* Cache.makeWith({ + capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + lookup: (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)), + timeToLive: Exit.match({ + onSuccess: (value) => + value === null + ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) + : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }); + + const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( + "RepositoryIdentityResolver.resolve", + )(function* (cwd) { + const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd)); + return yield* Cache.get(repositoryIdentityCache, cacheKey); + }); + + return { + resolve, + } satisfies RepositoryIdentityResolverShape; + }, +); + +export const RepositoryIdentityResolverLive = Layer.effect( + RepositoryIdentityResolver, + makeRepositoryIdentityResolver(), +); diff --git a/apps/server/src/project/Services/RepositoryIdentityResolver.ts b/apps/server/src/project/Services/RepositoryIdentityResolver.ts new file mode 100644 index 0000000000..2847cbca11 --- /dev/null +++ b/apps/server/src/project/Services/RepositoryIdentityResolver.ts @@ -0,0 +1,12 @@ +import type { RepositoryIdentity } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface RepositoryIdentityResolverShape { + readonly resolve: (cwd: string) => Effect.Effect; +} + +export class RepositoryIdentityResolver extends ServiceMap.Service< + RepositoryIdentityResolver, + RepositoryIdentityResolverShape +>()("t3/project/Services/RepositoryIdentityResolver") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 072e1ca172..125cfd103a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, DEFAULT_SERVER_SETTINGS, + EnvironmentId, + EventId, GitCommandError, KeybindingRule, MessageId, @@ -83,6 +85,14 @@ import { ProjectSetupScriptRunner, type ProjectSetupScriptRunnerShape, } from "./project/Services/ProjectSetupScriptRunner.ts"; +import { + RepositoryIdentityResolver, + type RepositoryIdentityResolverShape, +} from "./project/Services/RepositoryIdentityResolver.ts"; +import { + ServerEnvironment, + type ServerEnvironmentShape, +} from "./environment/Services/ServerEnvironment.ts"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; @@ -93,6 +103,18 @@ const defaultModelSelection = { provider: "codex", model: "gpt-5-codex", } as const; +const testEnvironmentDescriptor = { + environmentId: EnvironmentId.makeUnsafe("environment-test"), + label: "Test environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; const makeDefaultOrchestrationReadModel = () => { const now = new Date().toISOString(); @@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: { browserTraceCollector?: Partial; serverLifecycleEvents?: Partial; serverRuntimeStartup?: Partial; + serverEnvironment?: Partial; + repositoryIdentityResolver?: Partial; }; }) => Effect.gen(function* () { @@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: { ...options?.layers?.serverRuntimeStartup, }), ), + Layer.provide( + Layer.mock(ServerEnvironment)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + getDescriptor: Effect.succeed(testEnvironmentDescriptor), + ...options?.layers?.serverEnvironment, + }), + ), + Layer.provide( + Layer.mock(RepositoryIdentityResolver)({ + resolve: () => Effect.succeed(null), + ...options?.layers?.repositoryIdentityResolver, + }), + ), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { sequence: 1, type: "welcome" as const, payload: { + environment: testEnvironmentDescriptor, cwd: "/tmp/project", projectName: "project", }, @@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { version: 1 as const, sequence: 2, type: "ready" as const, - payload: { at: new Date().toISOString() }, + payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor }, }); yield* buildAppUnderTest({ @@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("enriches replayed project events with repository identity metadata", () => + Effect.gen(function* () { + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:T3Tools/t3code.git", + }, + displayName: "T3Tools/t3code", + provider: "github", + owner: "T3Tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + readEvents: (_fromSequenceExclusive) => + Stream.make({ + sequence: 1, + eventId: EventId.makeUnsafe("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-05T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.created", + payload: { + projectId: defaultProjectId, + title: "Default Project", + workspaceRoot: "/tmp/default-project", + defaultModelSelection, + scripts: [], + createdAt: "2026-04-05T00:00:00.000Z", + updatedAt: "2026-04-05T00:00:00.000Z", + }, + } satisfies Extract), + }, + repositoryIdentityResolver: { + resolve: () => Effect.succeed(repositoryIdentity), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const replayResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.replayEvents]({ + fromSequenceExclusive: 0, + }), + ), + ); + + const replayedEvent = replayResult[0]; + assert.equal(replayedEvent?.type, "project.created"); + assert.deepEqual( + replayedEvent && replayedEvent.type === "project.created" + ? replayedEvent.payload.repositoryIdentity + : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("closes thread terminals after a successful archive command", () => Effect.gen(function* () { const threadId = ThreadId.makeUnsafe("thread-archive"); @@ -2498,6 +2603,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("enriches replayed project events only once before streaming them to subscribers", () => + Effect.gen(function* () { + let resolveCalls = 0; + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + }, + displayName: "t3tools/t3code", + provider: "github" as const, + owner: "t3tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + getReadModel: () => + Effect.succeed({ + ...makeDefaultOrchestrationReadModel(), + snapshotSequence: 0, + }), + readEvents: () => + Stream.make({ + sequence: 1, + eventId: EventId.makeUnsafe("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-06T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.meta-updated", + payload: { + projectId: defaultProjectId, + title: "Replayed Project", + updatedAt: "2026-04-06T00:00:00.000Z", + }, + } satisfies Extract), + streamDomainEvents: Stream.empty, + }, + repositoryIdentityResolver: { + resolve: () => { + resolveCalls += 1; + return Effect.succeed(repositoryIdentity); + }, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const event = Array.from(events)[0]; + assert.equal(resolveCalls, 1); + assert.equal(event?.type, "project.meta-updated"); + assert.deepEqual( + event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("enriches subscribed project meta updates with repository identity metadata", () => + Effect.gen(function* () { + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "upstream", + remoteUrl: "git@github.com:T3Tools/t3code.git", + }, + displayName: "T3Tools/t3code", + provider: "github", + owner: "T3Tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + getReadModel: () => + Effect.succeed({ + ...makeDefaultOrchestrationReadModel(), + snapshotSequence: 0, + }), + streamDomainEvents: Stream.make({ + sequence: 1, + eventId: EventId.makeUnsafe("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-05T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.meta-updated", + payload: { + projectId: defaultProjectId, + title: "Renamed Project", + updatedAt: "2026-04-05T00:00:00.000Z", + }, + } satisfies Extract), + }, + repositoryIdentityResolver: { + resolve: () => Effect.succeed(repositoryIdentity), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const event = Array.from(events)[0]; + assert.equal(event?.type, "project.meta-updated"); + assert.deepEqual( + event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration.getSnapshot errors", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d6f6ac66e..d706d79b44 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; import { ServerSettingsLive } from "./serverSettings"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; import { ObservabilityLive } from "./observability/Layers/Observability"; +import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(ServerEnvironmentLive), // Misc. Layer.provideMerge(AnalyticsServiceLayerLive), diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts index 1cd8c25c03..cfa5c553a9 100644 --- a/apps/server/src/serverLifecycleEvents.test.ts +++ b/apps/server/src/serverLifecycleEvents.test.ts @@ -1,3 +1,4 @@ +import { EnvironmentId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertTrue } from "@effect/vitest/utils"; import { Effect, Option } from "effect"; @@ -9,12 +10,20 @@ it.effect( () => Effect.gen(function* () { const lifecycleEvents = yield* ServerLifecycleEvents; + const environment = { + environmentId: EnvironmentId.makeUnsafe("environment-test"), + label: "Test environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }; const welcome = yield* lifecycleEvents .publish({ version: 1, type: "welcome", payload: { + environment, cwd: "/tmp/project", projectName: "project", }, @@ -29,6 +38,7 @@ it.effect( type: "ready", payload: { at: new Date().toISOString(), + environment, }, }) .pipe(Effect.timeoutOption("50 millis")); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 7c9231ac93..e94c322225 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerSettingsService } from "./serverSettings"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; const isWildcardHost = (host: string | undefined): boolean => @@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { const orchestrationReactor = yield* OrchestrationReactor; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; + const serverEnvironment = yield* ServerEnvironment; const commandGate = yield* makeCommandGate; const httpListening = yield* Deferred.make(); @@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: preparing welcome payload"); const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); + const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: publishing welcome event", { + environmentId: environment.environmentId, cwd: welcome.cwd, projectName: welcome.projectName, bootstrapProjectId: welcome.bootstrapProjectId, @@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { lifecycleEvents.publish({ version: 1, type: "welcome", - payload: welcome, + payload: { + environment, + ...welcome, + }, }), ); }).pipe( @@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { lifecycleEvents.publish({ version: 1, type: "ready", - payload: { at: new Date().toISOString() }, + payload: { + at: new Date().toISOString(), + environment: yield* serverEnvironment.getDescriptor, + }, }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ca096bff33..16e8531386 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; +import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; const WsRpcLayer = WsRpcGroup.toLayer( Effect.gen(function* () { @@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + const serverEnvironment = yield* ServerEnvironment; const serverCommandId = (tag: string) => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer( }); }; + const enrichProjectEvent = ( + event: OrchestrationEvent, + ): Effect.Effect => { + switch (event.type) { + case "project.created": + return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( + Effect.map((repositoryIdentity) => ({ + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + })), + ); + case "project.meta-updated": + return Effect.gen(function* () { + const workspaceRoot = + event.payload.workspaceRoot ?? + (yield* orchestrationEngine.getReadModel()).projects.find( + (project) => project.id === event.payload.projectId, + )?.workspaceRoot ?? + null; + if (workspaceRoot === null) { + return event; + } + + const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); + return { + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + } satisfies OrchestrationEvent; + }); + default: + return Effect.succeed(event); + } + }; + + const enrichOrchestrationEvents = (events: ReadonlyArray) => + Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; const settings = yield* serverSettings.getSettings; + const environment = yield* serverEnvironment.getDescriptor; return { + environment, cwd: config.cwd, keybindingsConfigPath: config.keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, @@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( ), ).pipe( Effect.map((events) => Array.from(events)), + Effect.flatMap(enrichOrchestrationEvents), Effect.mapError( (cause) => new OrchestrationReplayEventsError({ @@ -455,10 +505,14 @@ const WsRpcLayer = WsRpcGroup.toLayer( orchestrationEngine.readEvents(fromSequenceExclusive), ).pipe( Effect.map((events) => Array.from(events)), + Effect.flatMap(enrichOrchestrationEvents), Effect.catch(() => Effect.succeed([] as Array)), ); const replayStream = Stream.fromIterable(replayEvents); - const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents); + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.mapEffect(enrichProjectEvent), + ); + const source = Stream.merge(replayStream, liveStream); type SequenceState = { readonly nextSequence: number; readonly pendingBySequence: Map; diff --git a/apps/web/index.html b/apps/web/index.html index 0322f2d019..45f30f7164 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -12,6 +12,7 @@ rel="stylesheet" /> T3 Code (Alpha) +
diff --git a/apps/web/package.json b/apps/web/package.json index 499943c3f0..d127743705 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 92929f78fc..cbcaf6f2fe 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,11 +1,13 @@ -import type { ThreadId } from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { FolderIcon, GitForkIcon } from "lucide-react"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; +import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { readEnvironmentApi } from "../environmentApi"; import { newCommandId } from "../lib/utils"; -import { readNativeApi } from "../nativeApi"; -import { useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { EnvMode, resolveDraftEnvModeAfterBranchChange, @@ -20,7 +22,9 @@ const envModeItems = [ ] as const; interface BranchToolbarProps { + environmentId: EnvironmentId; threadId: ThreadId; + draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; @@ -28,21 +32,34 @@ interface BranchToolbarProps { } export default function BranchToolbar({ + environmentId, threadId, + draftId, onEnvModeChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarProps) { - const serverThread = useStore((store) => store.threadShellById[threadId]); - const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null); + const threadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); + const serverThread = useStore(serverThreadSelector); + const serverSession = serverThread?.session ?? null; const setThreadBranchAction = useStore((store) => store.setThreadBranch); - const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; - const activeProject = useStore((store) => - activeProjectId ? store.projectById[activeProjectId] : undefined, + const activeProjectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const activeProjectSelector = useMemo( + () => createProjectSelectorByRef(activeProjectRef), + [activeProjectRef], ); + const activeProject = useStore(activeProjectSelector); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; @@ -56,8 +73,8 @@ export default function BranchToolbar({ const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null) => { - if (!activeThreadId) return; - const api = readNativeApi(); + if (!activeThreadId || !activeProject) return; + const api = readEnvironmentApi(environmentId); // If the effective cwd is about to change, stop the running session so the // next message creates a new one with the correct cwd. if (serverSession && worktreePath !== activeWorktreePath && api) { @@ -88,20 +105,24 @@ export default function BranchToolbar({ currentWorktreePath: activeWorktreePath, effectiveEnvMode, }); - setDraftThreadContext(threadId, { + setDraftThreadContext(draftId ?? threadRef, { branch, worktreePath, envMode: nextDraftEnvMode, + projectRef: scopeProjectRef(environmentId, activeProject.id), }); }, [ activeThreadId, + activeProject, serverSession, activeWorktreePath, hasServerThread, setThreadBranchAction, setDraftThreadContext, - threadId, + draftId, + threadRef, + environmentId, effectiveEnvMode, ], ); @@ -156,6 +177,7 @@ export default function BranchToolbar({ )} { if (!branchCwd) return; void queryClient.prefetchInfiniteQuery( - gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }), + gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }), ); - }, [branchCwd, queryClient]); + }, [branchCwd, environmentId, queryClient]); const { data: branchesSearchData, @@ -104,6 +106,7 @@ export function BranchToolbarBranchSelector({ isPending: isBranchesSearchPending, } = useInfiniteQuery( gitBranchSearchInfiniteQueryOptions({ + environmentId, cwd: branchCwd, query: deferredTrimmedBranchQuery, }), @@ -184,13 +187,13 @@ export function BranchToolbarBranchSelector({ startBranchActionTransition(async () => { await action().catch(() => undefined); await queryClient - .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) }) + .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) }) .catch(() => undefined); }); }; const selectBranch = (branch: GitBranch) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !branchCwd || isBranchActionPending) return; // In new-worktree mode, selecting a branch sets the base branch. @@ -248,7 +251,7 @@ export function BranchToolbarBranchSelector({ const createBranch = (rawName: string) => { const name = rawName.trim(); - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !branchCwd || !name || isBranchActionPending) return; setIsBranchMenuOpen(false); @@ -302,10 +305,10 @@ export function BranchToolbarBranchSelector({ return; } void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.branches(branchCwd), + queryKey: gitQueryKeys.branches(environmentId, branchCwd), }); }, - [branchCwd, queryClient], + [branchCwd, environmentId, queryClient], ); const branchListScrollElementRef = useRef(null); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b364a8e3a1..11926cd95c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -22,7 +22,7 @@ import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; -import { readNativeApi } from "../nativeApi"; +import { readLocalApi } from "../localApi"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -253,7 +253,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - const api = readNativeApi(); + const api = readLocalApi(); if (api) { void openInPreferredEditor(api, targetPath); } else { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 74968e1b3c..f0c4a52c5f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -4,6 +4,7 @@ import "../index.css"; import { EventId, ORCHESTRATION_WS_METHODS, + EnvironmentId, type MessageId, type OrchestrationEvent, type OrchestrationReadModel, @@ -12,11 +13,16 @@ import { type ServerLifecycleWelcomePayload, type ThreadId, type TurnId, - type UserInputQuestion, WS_METHODS, OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; @@ -24,19 +30,20 @@ import { page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore, DraftId } from "../composerDraftStore"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, removeInlineTerminalContextPlaceholder, } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; -import { __resetNativeApiForTests } from "../nativeApi"; +import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; -import { useStore } from "../store"; +import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; +import { useUiStateStore } from "../uiStateStore"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; @@ -49,8 +56,13 @@ vi.mock("../lib/gitStatusState", () => ({ })); const THREAD_ID = "thread-browser-test" as ThreadId; -const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); +const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); +const THREAD_KEY = scopedThreadKey(THREAD_REF); +const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; +const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID)); const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -129,6 +141,13 @@ function isoAt(offsetSeconds: number): string { function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -314,6 +333,13 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { snapshot, serverConfig: createBaseServerConfig(), welcome: { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", projectName: "Project", bootstrapProjectId: PROJECT_ID, @@ -396,6 +422,33 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest }; } +function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { + return { + sequence, + eventId: EventId.makeUnsafe(`event-thread-session-set-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: NOW_ISO, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.session-set", + payload: { + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: `turn-${threadId}` as TurnId, + lastError: null, + updatedAt: NOW_ISO, + }, + }, + }; +} + function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); } @@ -419,25 +472,72 @@ async function waitForWsClient(): Promise { ); } +function threadRefFor(threadId: ThreadId) { + return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); +} + +function threadKeyFor(threadId: ThreadId): string { + return scopedThreadKey(threadRefFor(threadId)); +} + +function composerDraftFor(target: string) { + const { draftsByThreadKey } = useComposerDraftStore.getState(); + return draftsByThreadKey[target] ?? draftsByThreadKey[threadKeyFor(target as ThreadId)]; +} + +function draftIdFromPath(pathname: string) { + const segments = pathname.split("/"); + const draftId = segments[segments.length - 1]; + if (!draftId) { + throw new Error(`Expected thread path, received "${pathname}".`); + } + return DraftId.makeUnsafe(draftId); +} + +function draftThreadIdFor(draftId: ReturnType): ThreadId { + const draftSession = useComposerDraftStore.getState().getDraftSession(draftId); + if (!draftSession) { + throw new Error(`Expected draft session for "${draftId}".`); + } + return draftSession.threadId; +} + +function serverThreadPath(threadId: ThreadId): string { + return `/${LOCAL_ENVIRONMENT_ID}/${threadId}`; +} + async function waitForAppBootstrap(): Promise { await vi.waitFor( () => { expect(getServerConfig()).not.toBeNull(); - expect(useStore.getState().bootstrapComplete).toBe(true); + expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true); }, { timeout: 8_000, interval: 16 }, ); } -async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { +async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise { await waitForWsClient(); fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); sendOrchestrationDomainEvent( createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), ); +} + +async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise { + sendOrchestrationDomainEvent( + createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1), + ); +} + +async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { + await materializePromotedDraftThreadViaDomainEvent(threadId); + await startPromotedServerThreadViaDomainEvent(threadId); await vi.waitFor( () => { - expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[threadKeyFor(threadId)]).toBe( + undefined, + ); }, { timeout: 8_000, interval: 16 }, ); @@ -468,9 +568,12 @@ function withProjectScripts( function setDraftThreadWithoutWorktree(): void { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -479,8 +582,8 @@ function setDraftThreadWithoutWorktree(): void { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); } @@ -542,51 +645,12 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } -function createSnapshotWithPendingUserInput(options?: { - questions?: ReadonlyArray; -}): OrchestrationReadModel { +function createSnapshotWithPendingUserInput(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-pending-input-target" as MessageId, targetText: "question thread", }); - const questions = - options?.questions ?? - ([ - { - id: "scope", - header: "Scope", - question: "What should this change cover?", - options: [ - { - label: "Tight", - description: "Touch only the footer layout logic.", - }, - { - label: "Broad", - description: "Also adjust the related composer controls.", - }, - ], - multiSelect: false, - }, - { - id: "risk", - header: "Risk", - question: "How aggressive should the imaginary plan be?", - options: [ - { - label: "Conservative", - description: "Favor reliability and low-risk changes.", - }, - { - label: "Balanced", - description: "Mix quick wins with one structural improvement.", - }, - ], - multiSelect: false, - }, - ] satisfies ReadonlyArray); - return { ...snapshot, threads: snapshot.threads.map((thread) => @@ -601,7 +665,38 @@ function createSnapshotWithPendingUserInput(options?: { summary: "User input requested", payload: { requestId: "req-browser-user-input", - questions, + questions: [ + { + id: "scope", + header: "Scope", + question: "What should this change cover?", + options: [ + { + label: "Tight", + description: "Touch only the footer layout logic.", + }, + { + label: "Broad", + description: "Also adjust the related composer controls.", + }, + ], + }, + { + id: "risk", + header: "Risk", + question: "How aggressive should the imaginary plan be?", + options: [ + { + label: "Conservative", + description: "Favor reliability and low-risk changes.", + }, + { + label: "Balanced", + description: "Mix quick wins with one structural improvement.", + }, + ], + }, + ], }, turnId: null, sequence: 1, @@ -1057,7 +1152,7 @@ async function mountChatView(options: { const router = getRouter( createMemoryHistory({ - initialEntries: [`/${THREAD_ID}`], + initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], }), ); @@ -1162,42 +1257,32 @@ describe("ChatView timeline estimator parity (full app)", () => { return []; }, }); - await __resetNativeApiForTests(); + await __resetLocalApiForTests(); await setViewport(DEFAULT_VIEWPORT); localStorage.clear(); document.body.innerHTML = ""; wsRequests.length = 0; customWsRpcResolver = null; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); useStore.setState({ - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: false, + activeEnvironmentId: null, + environmentStateById: {}, + }); + useUiStateStore.setState({ + projectExpandedById: {}, + projectOrder: [], + threadLastVisitedAtById: {}, }); useTerminalStateStore.persist.clearStorage(); useTerminalStateStore.setState({ - terminalStateByThreadId: {}, - terminalLaunchContextByThreadId: {}, + terminalStateByThreadKey: {}, + terminalLaunchContextByThreadKey: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, }); @@ -1241,6 +1326,35 @@ describe("ChatView timeline estimator parity (full app)", () => { }, ); + it("re-expands the bootstrap project using its scoped key", async () => { + useUiStateStore.setState({ + projectExpandedById: { + [PROJECT_KEY]: false, + }, + projectOrder: [PROJECT_KEY], + threadLastVisitedAtById: {}, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-bootstrap-project-expand" as MessageId, + targetText: "bootstrap project expand", + }), + }); + + try { + await vi.waitFor( + () => { + expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { const userText = "x".repeat(3_200); const targetMessageId = "msg-user-target-resize" as MessageId; @@ -1438,8 +1552,8 @@ describe("ChatView timeline estimator parity (full app)", () => { } useTerminalStateStore.setState({ - terminalStateByThreadId: { - [THREAD_ID]: { + terminalStateByThreadKey: { + [THREAD_KEY]: { terminalOpen: true, terminalHeight: 280, terminalIds: ["default"], @@ -1449,8 +1563,8 @@ describe("ChatView timeline estimator parity (full app)", () => { activeTerminalGroupId: "group-default", }, }, - terminalLaunchContextByThreadId: { - [THREAD_ID]: { + terminalLaunchContextByThreadKey: { + [THREAD_KEY]: { cwd: "/repo/project", worktreePath: null, }, @@ -1696,9 +1810,12 @@ describe("ChatView timeline estimator parity (full app)", () => { it("runs project scripts from local draft threads at the project cwd", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1707,8 +1824,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1772,9 +1889,12 @@ describe("ChatView timeline estimator parity (full app)", () => { it("runs project scripts from worktree draft threads at the worktree cwd", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1783,8 +1903,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1835,9 +1955,12 @@ describe("ChatView timeline estimator parity (full app)", () => { it("lets the server own setup after preparing a pull request worktree thread", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1846,8 +1969,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1957,12 +2080,15 @@ describe("ChatView timeline estimator parity (full app)", () => { it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { useTerminalStateStore.setState({ - terminalStateByThreadId: {}, + terminalStateByThreadKey: {}, }); useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1971,8 +2097,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1998,7 +2124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); await waitForLayout(); const sendButton = await waitForSendButton(); @@ -2057,12 +2183,15 @@ describe("ChatView timeline estimator parity (full app)", () => { it("shows the send state once bootstrap dispatch is in flight", async () => { useTerminalStateStore.setState({ - terminalStateByThreadId: {}, + terminalStateByThreadKey: {}, }); useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -2071,8 +2200,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -2101,7 +2230,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); await waitForLayout(); const sendButton = await waitForSendButton(); @@ -2193,7 +2322,7 @@ describe("ChatView timeline estimator parity (full app)", () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-removed", terminalLabel: "Terminal 1", @@ -2220,21 +2349,21 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const store = useComposerDraftStore.getState(); - const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; + const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); - store.setPrompt(THREAD_ID, nextPrompt.prompt); - store.removeTerminalContext(THREAD_ID, "ctx-removed"); + store.setPrompt(THREAD_REF, nextPrompt.prompt); + store.removeTerminalContext(THREAD_REF, "ctx-removed"); await vi.waitFor( () => { - expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); expect(document.body.textContent).not.toContain(removedLabel); }, { timeout: 8_000, interval: 16 }, ); useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-added", terminalLabel: "Terminal 2", @@ -2246,7 +2375,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]; + const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); expect(document.body.textContent).toContain(addedLabel); expect(document.body.textContent).not.toContain(removedLabel); @@ -2261,7 +2390,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("disables send when the composer only contains an expired terminal pill", async () => { const expiredLabel = "Terminal 1 line 4"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-expired-only", terminalLabel: "Terminal 1", @@ -2297,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("warns when sending text while omitting expired terminal pills", async () => { const expiredLabel = "Terminal 1 line 4"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-expired-send-warning", terminalLabel: "Terminal 1", @@ -2308,7 +2437,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); useComposerDraftStore .getState() - .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); + .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -2448,7 +2577,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("keeps the new thread selected after clicking the new-thread button", async () => { + it("canonicalizes promoted draft threads to the server thread route", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ @@ -2470,21 +2599,34 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); + const newThreadId = draftThreadIdFor(newDraftId); // The composer editor should be present for the new draft thread. await waitForComposerEditor(); - // Simulate the steady-state promotion path: the server emits - // `thread.created`, the client materializes the thread incrementally, - // and the draft is cleared by live batch effects. - await promoteDraftThreadViaDomainEvent(newThreadId); + // `thread.created` should only mark the draft as promoting; it should + // not navigate away until the server thread has actual runtime state. + await materializePromotedDraftThreadViaDomainEvent(newThreadId); + expect(mounted.router.state.location.pathname).toBe(newThreadPath); + await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); + + // Once the server thread starts, the route should canonicalize. + await startPromotedServerThreadViaDomainEvent(newThreadId); + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[newDraftId]).toBe( + undefined, + ); + }, + { timeout: 8_000, interval: 16 }, + ); - // The route should still be on the new thread — not redirected away. + // The route should switch to the canonical server thread path. await waitForURL( mounted.router, - (path) => path === newThreadPath, - "New thread should remain selected after server thread promotion clears the draft.", + (path) => path === serverThreadPath(newThreadId), + "Promoted drafts should canonicalize to the server thread route.", ); // The empty thread view and composer should still be visible. @@ -2497,6 +2639,48 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("canonicalizes stale promoted draft routes to the server thread route", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-draft-hydration-race-test" as MessageId, + targetText: "draft hydration race test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", + ); + const newDraftId = draftIdFromPath(newThreadPath); + const newThreadId = draftThreadIdFor(newDraftId); + + await promoteDraftThreadViaDomainEvent(newThreadId); + + await mounted.router.navigate({ + to: "/draft/$draftId", + params: { draftId: newDraftId }, + }); + + await waitForURL( + mounted.router, + (path) => path === serverThreadPath(newThreadId), + "Stale promoted draft routes should canonicalize to the server thread path.", + ); + + await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + it("snapshots sticky codex settings into a new draft thread", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { @@ -2531,9 +2715,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2584,9 +2768,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new sticky claude draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { claudeAgent: { provider: "claudeAgent", @@ -2624,9 +2808,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined(); + expect(composerDraftFor(newDraftId)).toBe(undefined); } finally { await mounted.cleanup(); } @@ -2666,9 +2850,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a sticky draft thread UUID.", ); - const threadId = threadPath.slice(1) as ThreadId; + const draftId = draftIdFromPath(threadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2681,7 +2865,7 @@ describe("ChatView timeline estimator parity (full app)", () => { activeProvider: "codex", }); - useComposerDraftStore.getState().setModelSelection(threadId, { + useComposerDraftStore.getState().setModelSelection(draftId, { provider: "codex", model: "gpt-5.4", options: { @@ -2697,7 +2881,7 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => path === threadPath, "New-thread should reuse the existing project draft thread.", ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2804,7 +2988,8 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a promoted draft thread UUID.", ); - const promotedThreadId = promotedThreadPath.slice(1) as ThreadId; + const promotedDraftId = draftIdFromPath(promotedThreadPath); + const promotedThreadId = draftThreadIdFor(promotedDraftId); await promoteDraftThreadViaDomainEvent(promotedThreadId); @@ -2925,70 +3110,10 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("does not trigger numeric option shortcuts while the composer is focused", async () => { + it("submits pending user input after the final option selection resolves the draft answers", async () => { const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, + viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotWithPendingUserInput(), - }); - - try { - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - - const event = new KeyboardEvent("keydown", { - key: "2", - bubbles: true, - cancelable: true, - }); - composerEditor.dispatchEvent(event); - await waitForLayout(); - - expect(event.defaultPrevented).toBe(false); - expect(document.body.textContent).toContain("What should this change cover?"); - expect(document.body.textContent).not.toContain( - "How aggressive should the imaginary plan be?", - ); - await waitForButtonByText("Next question"); - } finally { - await mounted.cleanup(); - } - }); - - it("submits multi-select questionnaire answers as arrays", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPendingUserInput({ - questions: [ - { - id: "scope", - header: "Scope", - question: "Which areas should this change cover?", - options: [ - { - label: "Server", - description: "Touch server orchestration.", - }, - { - label: "Web", - description: "Touch the browser UI.", - }, - ], - multiSelect: true, - }, - { - id: "risk", - header: "Risk", - question: "How aggressive should the imaginary plan be?", - options: [ - { - label: "Balanced", - description: "Mix quick wins with one structural improvement.", - }, - ], - multiSelect: false, - }, - ], - }), resolveRpc: (body) => { if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { return { @@ -3000,37 +3125,11 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const serverOption = await waitForButtonContainingText("Server"); - serverOption.click(); - await waitForLayout(); - - expect(document.body.textContent).toContain("Which areas should this change cover?"); - - const webOption = await waitForButtonContainingText("Web"); - webOption.click(); - await waitForLayout(); - - expect(document.body.textContent).toContain("Which areas should this change cover?"); - - const nextButton = await waitForButtonByText("Next question"); - expect(nextButton.disabled).toBe(false); - nextButton.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain( - "How aggressive should the imaginary plan be?", - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - const balancedOption = await waitForButtonContainingText("Balanced"); - balancedOption.click(); + const firstOption = await waitForButtonContainingText("Tight"); + firstOption.click(); - const submitButton = await waitForButtonByText("Submit answers"); - expect(submitButton.disabled).toBe(false); - submitButton.click(); + const finalOption = await waitForButtonContainingText("Conservative"); + finalOption.click(); await vi.waitFor( () => { @@ -3042,6 +3141,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | { _tag: string; type?: string; + requestId?: string; answers?: Record; } | undefined; @@ -3049,9 +3149,10 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(dispatchRequest).toMatchObject({ _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, type: "thread.user-input.respond", + requestId: "req-browser-user-input", answers: { - scope: ["Server", "Web"], - risk: "Balanced", + scope: "Tight", + risk: "Conservative", }, }); }, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cad565247d..ce82b3f77c 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,6 +1,7 @@ -import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { useStore } from "../store"; +import { type EnvironmentState, useStore } from "../store"; import { type Thread } from "../types"; import { @@ -10,9 +11,12 @@ import { deriveComposerSendState, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, + shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, } from "./ChatView.logic"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ @@ -170,6 +174,36 @@ describe("reconcileMountedTerminalThreadIds", () => { }); }); +describe("shouldWriteThreadErrorToCurrentServerThread", () => { + it("routes errors to the active server thread when route and target match", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId); + + expect( + shouldWriteThreadErrorToCurrentServerThread({ + serverThread: { + environmentId: localEnvironmentId, + id: threadId, + }, + routeThreadRef, + targetThreadId: threadId, + }), + ).toBe(true); + }); + + it("does not route draft-thread errors into server-backed state", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + + expect( + shouldWriteThreadErrorToCurrentServerThread({ + serverThread: undefined, + routeThreadRef: scopeThreadRef(localEnvironmentId, threadId), + targetThreadId: threadId, + }), + ).toBe(false); + }); +}); + const makeThread = (input?: { id?: ThreadId; latestTurn?: { @@ -181,6 +215,7 @@ const makeThread = (input?: { } | null; }): Thread => ({ id: input?.id ?? ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", @@ -208,11 +243,12 @@ const makeThread = (input?: { function setStoreThreads(threads: ReadonlyArray>) { const projectId = ProjectId.makeUnsafe("project-1"); - useStore.setState({ + const environmentState: EnvironmentState = { projectIds: [projectId], projectById: { [projectId]: { id: projectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -233,6 +269,7 @@ function setStoreThreads(threads: ReadonlyArray>) thread.id, { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -301,6 +338,12 @@ function setStoreThreads(threads: ReadonlyArray>) ), sidebarThreadSummaryById: {}, bootstrapComplete: true, + }; + useStore.setState({ + activeEnvironmentId: localEnvironmentId, + environmentStateById: { + [localEnvironmentId]: environmentState, + }, }); } @@ -326,14 +369,16 @@ describe("waitForStartedServerThread", () => { }), ]); - await expect(waitForStartedServerThread(threadId)).resolves.toBe(true); + await expect( + waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)), + ).resolves.toBe(true); }); it("waits for the thread to start via subscription updates", async () => { const threadId = ThreadId.makeUnsafe("thread-wait"); setStoreThreads([makeThread({ id: threadId })]); - const promise = waitForStartedServerThread(threadId, 500); + const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); setStoreThreads([ makeThread({ @@ -376,7 +421,9 @@ describe("waitForStartedServerThread", () => { return originalSubscribe(listener); }); - await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true); + await expect( + waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500), + ).resolves.toBe(true); }); it("returns false after the timeout when the thread never starts", async () => { @@ -384,7 +431,7 @@ describe("waitForStartedServerThread", () => { const threadId = ThreadId.makeUnsafe("thread-timeout"); setStoreThreads([makeThread({ id: threadId })]); - const promise = waitForStartedServerThread(threadId, 500); + const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); await vi.advanceTimersByTimeAsync(500); @@ -414,6 +461,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("does not clear local dispatch before server state changes", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", @@ -450,6 +498,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("clears local dispatch when a new turn is already settled", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", @@ -495,6 +544,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("clears local dispatch when the session changes without an observed running phase", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 6a0aa4d0c8..ffcd0cb3f5 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,9 +1,16 @@ -import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { + type EnvironmentId, + ProjectId, + type ModelSelection, + type ScopedThreadRef, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; -import { selectThreadById, useStore } from "../store"; +import { selectThreadByRef, useStore } from "../store"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -24,6 +31,7 @@ export function buildLocalDraftThread( ): Thread { return { id: threadId, + environmentId: draftThread.environmentId, codexThreadId: null, projectId: draftThread.projectId, title: "New thread", @@ -44,13 +52,32 @@ export function buildLocalDraftThread( }; } +export function shouldWriteThreadErrorToCurrentServerThread(input: { + serverThread: + | { + environmentId: EnvironmentId; + id: ThreadId; + } + | null + | undefined; + routeThreadRef: ScopedThreadRef; + targetThreadId: ThreadId; +}): boolean { + return Boolean( + input.serverThread && + input.targetThreadId === input.routeThreadRef.threadId && + input.serverThread.environmentId === input.routeThreadRef.environmentId && + input.serverThread.id === input.targetThreadId, + ); +} + export function reconcileMountedTerminalThreadIds(input: { - currentThreadIds: ReadonlyArray; - openThreadIds: ReadonlyArray; - activeThreadId: ThreadId | null; + currentThreadIds: ReadonlyArray; + openThreadIds: ReadonlyArray; + activeThreadId: string | null; activeThreadTerminalOpen: boolean; maxHiddenThreadCount?: number; -}): ThreadId[] { +}): string[] { const openThreadIdSet = new Set(input.openThreadIds); const hiddenThreadIds = input.currentThreadIds.filter( (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), @@ -199,10 +226,10 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { } export async function waitForStartedServerThread( - threadId: ThreadId, + threadRef: ScopedThreadRef, timeoutMs = 1_000, ): Promise { - const getThread = () => selectThreadById(threadId)(useStore.getState()); + const getThread = () => selectThreadByRef(useStore.getState(), threadRef); const thread = getThread(); if (threadHasStarted(thread)) { @@ -225,7 +252,7 @@ export async function waitForStartedServerThread( }; const unsubscribe = useStore.subscribe((state) => { - if (!threadHasStarted(selectThreadById(threadId)(state))) { + if (!threadHasStarted(selectThreadByRef(state, threadRef))) { return; } finish(true); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 55dd63761c..cf09dd8a38 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, + type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, @@ -12,6 +13,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ServerProvider, + type ScopedThreadRef, type ThreadId, type TurnId, type KeybindingCommand, @@ -20,6 +22,12 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; @@ -27,9 +35,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; +import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { clampCollapsedComposerCursor, @@ -64,8 +75,8 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { useStore } from "../store"; -import { createThreadSelector } from "../storeSelectors"; +import { selectThreadsAcrossEnvironments, useStore } from "../store"; +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -114,8 +125,7 @@ import { projectScriptIdFromCommand, } from "~/projectScripts"; import { SidebarTrigger } from "./ui/sidebar"; -import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; -import { readNativeApi } from "~/nativeApi"; +import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, getProviderModels, @@ -124,6 +134,8 @@ import { import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { deriveLogicalProjectKey } from "../logicalProject"; +import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -131,6 +143,7 @@ import { useComposerDraftStore, useEffectiveComposerModelState, useComposerThreadDraft, + type DraftId, } from "../composerDraftStore"; import { appendTerminalContextsToPrompt, @@ -187,6 +200,7 @@ import { reconcileMountedTerminalThreadIds, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + shouldWriteThreadErrorToCurrentServerThread, threadHasStarted, waitForStartedServerThread, } from "./ChatView.logic"; @@ -203,6 +217,7 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; +const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; @@ -210,26 +225,115 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record; function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { - const threadShellById = useStore((state) => state.threadShellById); - const proposedPlanIdsByThreadId = useStore((state) => state.proposedPlanIdsByThreadId); - const proposedPlanByThreadId = useStore((state) => state.proposedPlanByThreadId); + return useStore( + useMemo(() => { + let previousThreadIds: readonly ThreadId[] = []; + let previousResult: ThreadPlanCatalogEntry[] = []; + let previousEntries = new Map< + ThreadId, + { + shell: object | null; + proposedPlanIds: readonly string[] | undefined; + proposedPlansById: Record | undefined; + entry: ThreadPlanCatalogEntry; + } + >(); + + return (state) => { + const sameThreadIds = + previousThreadIds.length === threadIds.length && + previousThreadIds.every((id, index) => id === threadIds[index]); + const nextEntries = new Map< + ThreadId, + { + shell: object | null; + proposedPlanIds: readonly string[] | undefined; + proposedPlansById: Record | undefined; + entry: ThreadPlanCatalogEntry; + } + >(); + const nextResult: ThreadPlanCatalogEntry[] = []; + let changed = !sameThreadIds; + + for (const threadId of threadIds) { + let shell: object | undefined; + let proposedPlanIds: readonly string[] | undefined; + let proposedPlansById: Record | undefined; + + for (const environmentState of Object.values(state.environmentStateById)) { + const matchedShell = environmentState.threadShellById[threadId]; + if (!matchedShell) { + continue; + } + shell = matchedShell; + proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; + proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as + | Record + | undefined; + break; + } - return useMemo( - () => - threadIds.flatMap((threadId) => { - if (!threadShellById[threadId]) { - return []; + if (!shell) { + const previous = previousEntries.get(threadId); + if ( + previous && + previous.shell === null && + previous.proposedPlanIds === undefined && + previous.proposedPlansById === undefined + ) { + nextEntries.set(threadId, previous); + continue; + } + changed = true; + nextEntries.set(threadId, { + shell: null, + proposedPlanIds: undefined, + proposedPlansById: undefined, + entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, + }); + continue; + } + + const previous = previousEntries.get(threadId); + if ( + previous && + previous.shell === shell && + previous.proposedPlanIds === proposedPlanIds && + previous.proposedPlansById === proposedPlansById + ) { + nextEntries.set(threadId, previous); + nextResult.push(previous.entry); + continue; + } + + changed = true; + const proposedPlans = + proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById + ? proposedPlanIds.flatMap((planId) => { + const proposedPlan = proposedPlansById?.[planId]; + return proposedPlan ? [proposedPlan] : []; + }) + : EMPTY_PROPOSED_PLANS; + const entry = { id: threadId, proposedPlans }; + nextEntries.set(threadId, { + shell, + proposedPlanIds, + proposedPlansById, + entry, + }); + nextResult.push(entry); } - const proposedPlans = - proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => { - const plan = proposedPlanByThreadId[threadId]?.[planId]; - return plan ? [plan] : []; - }) ?? []; + if (!changed && previousResult.length === nextResult.length) { + return previousResult; + } - return [{ id: threadId, proposedPlans }]; - }), - [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById], + previousThreadIds = threadIds; + previousEntries = nextEntries; + previousResult = nextResult; + return nextResult; + }; + }, [threadIds]), ); } @@ -278,9 +382,19 @@ const terminalContextIdListsEqual = ( ): boolean => contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); -interface ChatViewProps { - threadId: ThreadId; -} +type ChatViewProps = + | { + environmentId: EnvironmentId; + threadId: ThreadId; + routeKind: "server"; + draftId?: never; + } + | { + environmentId: EnvironmentId; + threadId: ThreadId; + routeKind: "draft"; + draftId: DraftId; + }; interface TerminalLaunchContext { threadId: ThreadId; @@ -358,6 +472,7 @@ function useLocalDispatchState(input: { } interface PersistentThreadTerminalDrawerProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; launchContext: PersistentTerminalLaunchContext | null; @@ -369,6 +484,7 @@ interface PersistentThreadTerminalDrawerProps { } function PersistentThreadTerminalDrawer({ + threadRef, threadId, visible, launchContext, @@ -378,19 +494,16 @@ function PersistentThreadTerminalDrawer({ closeShortcutLabel, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); - const draftThread = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] ?? null, - ); - const project = useStore((state) => - serverThread?.projectId - ? state.projectById[serverThread.projectId] - : draftThread?.projectId - ? state.projectById[draftThread.projectId] - : undefined, - ); + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadId, threadId), + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), ); const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); @@ -436,32 +549,32 @@ function PersistentThreadTerminalDrawer({ const setTerminalHeight = useCallback( (height: number) => { - storeSetTerminalHeight(threadId, height); + storeSetTerminalHeight(threadRef, height); }, - [storeSetTerminalHeight, threadId], + [storeSetTerminalHeight, threadRef], ); const splitTerminal = useCallback(() => { - storeSplitTerminal(threadId, `terminal-${randomUUID()}`); + storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); bumpFocusRequestId(); - }, [bumpFocusRequestId, storeSplitTerminal, threadId]); + }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); const createNewTerminal = useCallback(() => { - storeNewTerminal(threadId, `terminal-${randomUUID()}`); + storeNewTerminal(threadRef, `terminal-${randomUUID()}`); bumpFocusRequestId(); - }, [bumpFocusRequestId, storeNewTerminal, threadId]); + }, [bumpFocusRequestId, storeNewTerminal, threadRef]); const activateTerminal = useCallback( (terminalId: string) => { - storeSetActiveTerminal(threadId, terminalId); + storeSetActiveTerminal(threadRef, terminalId); bumpFocusRequestId(); }, - [bumpFocusRequestId, storeSetActiveTerminal, threadId], + [bumpFocusRequestId, storeSetActiveTerminal, threadRef], ); const closeTerminal = useCallback( (terminalId: string) => { - const api = readNativeApi(); + const api = readEnvironmentApi(threadRef.environmentId); if (!api) return; const isFinalTerminal = terminalState.terminalIds.length <= 1; const fallbackExitWrite = () => @@ -482,10 +595,10 @@ function PersistentThreadTerminalDrawer({ void fallbackExitWrite(); } - storeCloseTerminal(threadId, terminalId); + storeCloseTerminal(threadRef, terminalId); bumpFocusRequestId(); }, - [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId], + [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef], ); const handleAddTerminalContext = useCallback( @@ -505,6 +618,7 @@ function PersistentThreadTerminalDrawer({ return (
createThreadSelector(threadId), [threadId])); +export default function ChatView(props: ChatViewProps) { + const { environmentId, threadId, routeKind } = props; + const draftId = routeKind === "draft" ? props.draftId : null; + const routeThreadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const composerDraftTarget: ScopedThreadRef | DraftId = + routeKind === "server" ? routeThreadRef : props.draftId; + const serverThread = useStore( + useMemo( + () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ), + ); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[threadId], + const activeThreadLastVisitedAt = useUiStateStore((store) => + routeKind === "server" + ? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))] + : undefined, ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( @@ -548,7 +677,7 @@ export default function ChatView({ threadId }: ChatViewProps) { select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); - const composerDraft = useComposerThreadDraft(threadId); + const composerDraft = useComposerThreadDraft(composerDraftTarget); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -591,16 +720,20 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const getDraftThreadByProjectId = useComposerDraftStore( - (store) => store.getDraftThreadByProjectId, + const getDraftSessionByLogicalProjectKey = useComposerDraftStore( + (store) => store.getDraftSessionByLogicalProjectKey, ); - const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const clearProjectDraftThreadId = useComposerDraftStore( - (store) => store.clearProjectDraftThreadId, + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); + const setLogicalProjectDraftThreadId = useComposerDraftStore( + (store) => store.setLogicalProjectDraftThreadId, ); - const draftThread = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] ?? null, + const draftThread = useComposerDraftStore((store) => + routeKind === "server" + ? store.getDraftSessionByRef(routeThreadRef) + : draftId + ? store.getDraftSession(draftId) + : null, ); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -610,8 +743,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const composerTerminalContextsRef = useRef(composerTerminalContexts); - const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< - Record + const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< + Record >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); @@ -689,66 +822,73 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); - const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); - const terminalState = useMemo( - () => selectThreadTerminalState(terminalStateByThreadId, threadId), - [terminalStateByThreadId, threadId], + const terminalState = useTerminalStateStore((state) => + selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), ); - const openTerminalThreadIds = useMemo( - () => - Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) => - nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [], + const openTerminalThreadKeys = useTerminalStateStore( + useShallow((state) => + Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => + nextTerminalState.terminalOpen ? [nextThreadKey] : [], ), - [terminalStateByThreadId], + ), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); - const serverThreadIds = useStore((state) => state.threadIds); + const serverThreadKeys = useStore( + useShallow((state) => + selectThreadsAcrossEnvironments(state).map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + ); const storeServerTerminalLaunchContext = useTerminalStateStore( - (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, + (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null, ); const storeClearTerminalLaunchContext = useTerminalStateStore( (s) => s.clearTerminalLaunchContext, ); - const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); - const draftThreadIds = useMemo( - () => Object.keys(draftThreadsByThreadId) as ThreadId[], - [draftThreadsByThreadId], + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftThreadKeys = useMemo( + () => + Object.values(draftThreadsByThreadKey).map((draftThread) => + scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), + ), + [draftThreadsByThreadKey], ); - const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState([]); + const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); const setPrompt = useCallback( (nextPrompt: string) => { - setComposerDraftPrompt(threadId, nextPrompt); + setComposerDraftPrompt(composerDraftTarget, nextPrompt); }, - [setComposerDraftPrompt, threadId], + [composerDraftTarget, setComposerDraftPrompt], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { - addComposerDraftImage(threadId, image); + addComposerDraftImage(composerDraftTarget, image); }, - [addComposerDraftImage, threadId], + [addComposerDraftImage, composerDraftTarget], ); const addComposerImagesToDraft = useCallback( (images: ComposerImageAttachment[]) => { - addComposerDraftImages(threadId, images); + addComposerDraftImages(composerDraftTarget, images); }, - [addComposerDraftImages, threadId], + [addComposerDraftImages, composerDraftTarget], ); const addComposerTerminalContextsToDraft = useCallback( (contexts: TerminalContextDraft[]) => { - addComposerDraftTerminalContexts(threadId, contexts); + addComposerDraftTerminalContexts(composerDraftTarget, contexts); }, - [addComposerDraftTerminalContexts, threadId], + [addComposerDraftTerminalContexts, composerDraftTarget], ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { - removeComposerDraftImage(threadId, imageId); + removeComposerDraftImage(composerDraftTarget, imageId); }, - [removeComposerDraftImage, threadId], + [composerDraftTarget, removeComposerDraftImage], ); const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { @@ -761,7 +901,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); promptRef.current = nextPrompt.prompt; setPrompt(nextPrompt.prompt); - removeComposerDraftTerminalContext(threadId, contextId); + removeComposerDraftTerminalContext(composerDraftTarget, contextId); setComposerCursor(nextPrompt.cursor); setComposerTrigger( detectComposerTrigger( @@ -770,13 +910,19 @@ export default function ChatView({ threadId }: ChatViewProps) { ), ); }, - [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], + [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt], ); - const fallbackDraftProject = useStore((state) => - draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined, + const fallbackDraftProjectRef = draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const fallbackDraftProject = useStore( + useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), ); - const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); + const localDraftError = + routeKind === "server" && serverThread + ? null + : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); const localDraftThread = useMemo( () => draftThread @@ -792,20 +938,25 @@ export default function ChatView({ threadId }: ChatViewProps) { : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], ); - const activeThread = serverThread ?? localDraftThread; + const isServerThread = routeKind === "server" && serverThread !== undefined; + const activeThread = isServerThread ? serverThread : localDraftThread; const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; - const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; - const existingOpenTerminalThreadIds = useMemo(() => { - const existingThreadIds = new Set([...serverThreadIds, ...draftThreadIds]); - return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId)); - }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]); + const activeThreadRef = useMemo( + () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), + [activeThread], + ); + const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const existingOpenTerminalThreadKeys = useMemo(() => { + const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); + return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); + }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -825,12 +976,12 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread?.activities], ); useEffect(() => { - setMountedTerminalThreadIds((currentThreadIds) => { + setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ currentThreadIds, - openThreadIds: existingOpenTerminalThreadIds, - activeThreadId, - activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen), + openThreadIds: existingOpenTerminalThreadKeys, + activeThreadId: activeThreadKey, + activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen), maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && @@ -838,10 +989,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ? currentThreadIds : nextThreadIds; }); - }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); + }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = useStore((state) => - activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined, + const activeProjectRef = activeThread + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) + : null; + const activeProject = useStore( + useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); const openPullRequestDialog = useCallback( @@ -867,50 +1021,71 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeProject) { throw new Error("No active project is available for this pull request."); } - const storedDraftThread = getDraftThreadByProjectId(activeProject.id); - if (storedDraftThread) { - setDraftThreadContext(storedDraftThread.threadId, input); - setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); - if (storedDraftThread.threadId !== threadId) { + const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); + const logicalProjectKey = deriveLogicalProjectKey(activeProject); + const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); + if (storedDraftSession) { + setDraftThreadContext(storedDraftSession.draftId, input); + setLogicalProjectDraftThreadId( + logicalProjectKey, + activeProjectRef, + storedDraftSession.draftId, + { + threadId: storedDraftSession.threadId, + ...input, + }, + ); + if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(storedDraftSession.draftId), }); } - return storedDraftThread.threadId; + return storedDraftSession.threadId; } - const activeDraftThread = getDraftThread(threadId); - if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { - setDraftThreadContext(threadId, input); - setProjectDraftThreadId(activeProject.id, threadId, input); - return threadId; + const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; + if ( + !isServerThread && + activeDraftSession?.logicalProjectKey === logicalProjectKey && + draftId + ) { + setDraftThreadContext(draftId, input); + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { + threadId: activeDraftSession.threadId, + createdAt: activeDraftSession.createdAt, + runtimeMode: activeDraftSession.runtimeMode, + interactionMode: activeDraftSession.interactionMode, + ...input, + }); + return activeDraftSession.threadId; } - clearProjectDraftThreadId(activeProject.id); + const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); - setProjectDraftThreadId(activeProject.id, nextThreadId, { + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { + threadId: nextThreadId, createdAt: new Date().toISOString(), runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); await navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(nextDraftId), }); return nextThreadId; }, [ activeProject, - clearProjectDraftThreadId, - getDraftThread, - getDraftThreadByProjectId, + draftId, + getDraftSession, + getDraftSessionByLogicalProjectKey, isServerThread, navigate, + routeKind, setDraftThreadContext, - setProjectDraftThreadId, - threadId, + setLogicalProjectDraftThreadId, ], ); @@ -934,12 +1109,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - markThreadVisited(serverThread.id); + markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id))); }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, latestTurnSettled, markThreadVisited, + serverThread?.environmentId, serverThread?.id, ]); @@ -959,7 +1135,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ - threadId, + threadRef: composerDraftTarget, providers: providerStatuses, selectedProvider, threadModelSelection: activeThread?.modelSelection, @@ -1358,7 +1534,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const gitStatusQuery = useGitStatus(gitCwd); + const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( @@ -1394,6 +1570,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ + environmentId, cwd: gitCwd, query: effectivePathQuery, enabled: isPathTrigger, @@ -1531,16 +1708,22 @@ export default function ChatView({ threadId }: ChatViewProps) { [keybindings, nonTerminalShortcutLabelOptions], ); const onToggleDiff = useCallback(() => { + if (!isServerThread) { + return; + } void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); - }, [diffOpen, navigate, threadId]); + }, [diffOpen, environmentId, isServerThread, navigate, threadId]); const envLocked = Boolean( activeThread && @@ -1561,21 +1744,27 @@ export default function ChatView({ threadId }: ChatViewProps) { (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; const nextError = sanitizeThreadErrorMessage(error); - if (useStore.getState().threadShellById[targetThreadId] !== undefined) { + const isCurrentServerThread = shouldWriteThreadErrorToCurrentServerThread({ + serverThread, + routeThreadRef, + targetThreadId, + }); + if (isCurrentServerThread) { setStoreThreadError(targetThreadId, nextError); return; } - setLocalDraftErrorsByThreadId((existing) => { - if ((existing[targetThreadId] ?? null) === nextError) { + const localDraftErrorKey = draftId ?? targetThreadId; + setLocalDraftErrorsByDraftId((existing) => { + if ((existing[localDraftErrorKey] ?? null) === nextError) { return existing; } return { ...existing, - [targetThreadId]: nextError, + [localDraftErrorKey]: nextError, }; }); }, - [setStoreThreadError], + [draftId, routeThreadRef, serverThread, setStoreThreadError], ); const focusComposer = useCallback(() => { @@ -1606,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) { insertion.cursor, ); const inserted = insertComposerDraftTerminalContext( - activeThread.id, + scopeThreadRef(activeThread.environmentId, activeThread.id), insertion.prompt, { id: randomUUID(), @@ -1630,30 +1819,30 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const setTerminalOpen = useCallback( (open: boolean) => { - if (!activeThreadId) return; - storeSetTerminalOpen(activeThreadId, open); + if (!activeThreadRef) return; + storeSetTerminalOpen(activeThreadRef, open); }, - [activeThreadId, storeSetTerminalOpen], + [activeThreadRef, storeSetTerminalOpen], ); const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadRef) return; setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); + }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); const splitTerminal = useCallback(() => { - if (!activeThreadId || hasReachedSplitLimit) return; + if (!activeThreadRef || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; - storeSplitTerminal(activeThreadId, terminalId); + storeSplitTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); + }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); const createNewTerminal = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadRef) return; const terminalId = `terminal-${randomUUID()}`; - storeNewTerminal(activeThreadId, terminalId); + storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, storeNewTerminal]); + }, [activeThreadRef, storeNewTerminal]); const closeTerminal = useCallback( (terminalId: string) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!activeThreadId || !api) return; const isFinalTerminal = terminalState.terminalIds.length <= 1; const fallbackExitWrite = () => @@ -1676,10 +1865,18 @@ export default function ChatView({ threadId }: ChatViewProps) { } else { void fallbackExitWrite(); } - storeCloseTerminal(activeThreadId, terminalId); + if (activeThreadRef) { + storeCloseTerminal(activeThreadRef, terminalId); + } setTerminalFocusRequestId((value) => value + 1); }, - [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length], + [ + activeThreadId, + activeThreadRef, + environmentId, + storeCloseTerminal, + terminalState.terminalIds.length, + ], ); const runProjectScript = useCallback( async ( @@ -1692,7 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) { rememberAsLastInvoked?: boolean; }, ) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThreadId || !activeProject || !activeThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { @@ -1719,10 +1916,13 @@ export default function ChatView({ threadId }: ChatViewProps) { worktreePath: targetWorktreePath, }); setTerminalOpen(true); + if (!activeThreadRef) { + return; + } if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadId, targetTerminalId); + storeNewTerminal(activeThreadRef, targetTerminalId); } else { - storeSetActiveTerminal(activeThreadId, targetTerminalId); + storeSetActiveTerminal(activeThreadRef, targetTerminalId); } setTerminalFocusRequestId((value) => value + 1); @@ -1769,12 +1969,14 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeThread, activeThreadId, + activeThreadRef, gitCwd, setTerminalOpen, setThreadError, storeNewTerminal, storeSetActiveTerminal, setLastInvokedScriptByProjectId, + environmentId, terminalState.activeTerminalId, terminalState.runningTerminalIds, terminalState.terminalIds, @@ -1790,7 +1992,7 @@ export default function ChatView({ threadId }: ChatViewProps) { keybinding?: string | null; keybindingCommand: KeybindingCommand; }) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api) return; await api.orchestration.dispatchCommand({ @@ -1806,10 +2008,14 @@ export default function ChatView({ threadId }: ChatViewProps) { }); if (isElectron && keybindingRule) { - await api.server.upsertKeybinding(keybindingRule); + const localApi = readLocalApi(); + if (!localApi) { + throw new Error("Local API unavailable."); + } + await localApi.server.upsertKeybinding(keybindingRule); } }, - [], + [environmentId], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput) => { @@ -1913,9 +2119,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(threadId, mode); + setComposerDraftRuntimeMode(composerDraftTarget, mode); if (isLocalDraftThread) { - setDraftThreadContext(threadId, { runtimeMode: mode }); + setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); } scheduleComposerFocus(); }, @@ -1923,18 +2129,18 @@ export default function ChatView({ threadId }: ChatViewProps) { isLocalDraftThread, runtimeMode, scheduleComposerFocus, + composerDraftTarget, setComposerDraftRuntimeMode, setDraftThreadContext, - threadId, ], ); const handleInteractionModeChange = useCallback( (mode: ProviderInteractionMode) => { if (mode === interactionMode) return; - setComposerDraftInteractionMode(threadId, mode); + setComposerDraftInteractionMode(composerDraftTarget, mode); if (isLocalDraftThread) { - setDraftThreadContext(threadId, { interactionMode: mode }); + setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); } scheduleComposerFocus(); }, @@ -1942,9 +2148,9 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode, isLocalDraftThread, scheduleComposerFocus, + composerDraftTarget, setComposerDraftInteractionMode, setDraftThreadContext, - threadId, ], ); const toggleInteractionMode = useCallback(() => { @@ -1980,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!serverThread) { return; } - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api) { return; } @@ -2020,7 +2226,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } }, - [serverThread], + [environmentId, serverThread], ); // Auto-scroll on new messages @@ -2350,17 +2556,17 @@ export default function ChatView({ threadId }: ChatViewProps) { dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); - }, [resetLocalDispatch, threadId]); + }, [draftId, resetLocalDispatch, threadId]); useEffect(() => { let cancelled = false; void (async () => { if (composerImages.length === 0) { - clearComposerDraftPersistedAttachments(threadId); + clearComposerDraftPersistedAttachments(composerDraftTarget); return; } const getPersistedAttachmentsForThread = () => - useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; + getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); const existingPersistedById = new Map( @@ -2391,7 +2597,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } // Stage attachments in persisted draft state first so persist middleware can write them. - syncComposerDraftPersistedAttachments(threadId, serialized); + syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); } catch { const currentImageIds = new Set(composerImages.map((image) => image.id)); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); @@ -2405,17 +2611,18 @@ export default function ChatView({ threadId }: ChatViewProps) { if (cancelled) { return; } - syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); + syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); } })(); return () => { cancelled = true; }; }, [ + composerDraftTarget, clearComposerDraftPersistedAttachments, composerImages, + getComposerDraft, syncComposerDraftPersistedAttachments, - threadId, ]); const closeExpandedImage = useCallback(() => { @@ -2476,7 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { if (!activeThreadId) { setTerminalLaunchContext(null); - storeClearTerminalLaunchContext(threadId); + storeClearTerminalLaunchContext(routeThreadRef); return; } setTerminalLaunchContext((current) => { @@ -2484,7 +2691,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (current.threadId === activeThreadId) return current; return null; }); - }, [activeThreadId, storeClearTerminalLaunchContext, threadId]); + }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]); useEffect(() => { if (!activeThreadId || !activeProjectCwd) { @@ -2502,12 +2709,20 @@ export default function ChatView({ threadId }: ChatViewProps) { settledCwd === current.cwd && (activeThreadWorktreePath ?? null) === current.worktreePath ) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); + } return null; } return current; }); - }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]); + }, [ + activeProjectCwd, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + storeClearTerminalLaunchContext, + ]); useEffect(() => { if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { @@ -2521,11 +2736,14 @@ export default function ChatView({ threadId }: ChatViewProps) { settledCwd === storeServerTerminalLaunchContext.cwd && (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath ) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); + } } }, [ activeProjectCwd, activeThreadId, + activeThreadRef, activeThreadWorktreePath, storeClearTerminalLaunchContext, storeServerTerminalLaunchContext, @@ -2535,11 +2753,16 @@ export default function ChatView({ threadId }: ChatViewProps) { if (terminalState.terminalOpen) { return; } - if (activeThreadId) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); } setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); - }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]); + }, [ + activeThreadId, + activeThreadRef, + storeClearTerminalLaunchContext, + terminalState.terminalOpen, + ]); useEffect(() => { if (phase !== "running") return; @@ -2552,16 +2775,16 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [phase]); useEffect(() => { - if (!activeThreadId) return; - const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; + if (!activeThreadKey) return; + const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; const current = Boolean(terminalState.terminalOpen); if (!previous && current) { - terminalOpenByThreadRef.current[activeThreadId] = current; + terminalOpenByThreadRef.current[activeThreadKey] = current; setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { - terminalOpenByThreadRef.current[activeThreadId] = current; + terminalOpenByThreadRef.current[activeThreadKey] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); @@ -2570,8 +2793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }; } - terminalOpenByThreadRef.current[activeThreadId] = current; - }, [activeThreadId, focusComposer, terminalState.terminalOpen]); + terminalOpenByThreadRef.current[activeThreadKey] = current; + }, [activeThreadKey, focusComposer, terminalState.terminalOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -2766,14 +2989,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const onRevertToTurnCount = useCallback( async (turnCount: number) => { - const api = readNativeApi(); - if (!api || !activeThread || isRevertingCheckpoint) return; + const api = readEnvironmentApi(environmentId); + const localApi = readLocalApi(); + if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } - const confirmed = await api.dialogs.confirm( + const confirmed = await localApi.dialogs.confirm( [ `Revert this thread to checkpoint ${turnCount}?`, "This will discard newer messages and turn diffs in this thread.", @@ -2802,12 +3026,20 @@ export default function ChatView({ threadId }: ChatViewProps) { } setIsRevertingCheckpoint(false); }, - [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], + [ + activeThread, + environmentId, + isConnecting, + isRevertingCheckpoint, + isSendBusy, + phase, + setThreadError, + ], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); @@ -2830,7 +3062,7 @@ export default function ChatView({ threadId }: ChatViewProps) { planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; - clearComposerDraftContent(activeThread.id); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -2847,7 +3079,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; - clearComposerDraftContent(activeThread.id); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -2880,10 +3112,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const shouldCreateWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThread.branch) { - setStoreThreadError( - threadIdForSend, - "Select a base branch before sending in New worktree mode.", - ); + setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } @@ -2950,7 +3179,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } promptRef.current = ""; - clearComposerDraftContent(threadIdForSend); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -3087,7 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }; const onInterrupt = async () => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThread) return; await api.orchestration.dispatchCommand({ type: "thread.turn.interrupt", @@ -3099,7 +3328,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThreadId) return; setRespondingRequestIds((existing) => @@ -3122,12 +3351,12 @@ export default function ChatView({ threadId }: ChatViewProps) { }); setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, setThreadError], + [activeThreadId, environmentId, setThreadError], ); const onRespondToUserInput = useCallback( async (requestId: ApprovalRequestId, answers: Record) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThreadId) return; setRespondingUserInputRequestIds((existing) => @@ -3150,7 +3379,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, setThreadError], + [activeThreadId, environmentId, setThreadError], ); const setActivePendingUserInputQuestionIndex = useCallback( @@ -3166,31 +3395,38 @@ export default function ChatView({ threadId }: ChatViewProps) { [activePendingUserInput], ); - const onToggleActivePendingUserInputOption = useCallback( + const onSelectActivePendingUserInputOption = useCallback( (questionId: string, optionLabel: string) => { if (!activePendingUserInput) { return; } - const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question) { - return; - } - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: togglePendingUserInputOptionSelection( - question, - existing[activePendingUserInput.requestId]?.[questionId], - optionLabel, - ), - }, - })); + setPendingUserInputAnswersByRequestId((existing) => { + const question = + (activePendingProgress?.activeQuestion?.id === questionId + ? activePendingProgress.activeQuestion + : undefined) ?? + activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question) { + return existing; + } + + return { + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [questionId]: togglePendingUserInputOptionSelection( + question, + existing[activePendingUserInput.requestId]?.[questionId], + optionLabel, + ), + }, + }; + }); promptRef.current = ""; setComposerCursor(0); setComposerTrigger(null); }, - [activePendingUserInput], + [activePendingProgress?.activeQuestion, activePendingUserInput], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -3257,7 +3493,7 @@ export default function ChatView({ threadId }: ChatViewProps) { text: string; interactionMode: "default" | "plan"; }) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if ( !api || !activeThread || @@ -3312,7 +3548,10 @@ export default function ChatView({ threadId }: ChatViewProps) { // Keep the mode toggle and plan-follow-up banner in sync immediately // while the same-thread implementation turn is starting. - setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); + setComposerDraftInteractionMode( + scopeThreadRef(activeThread.environmentId, threadIdForSend), + nextInteractionMode, + ); await api.orchestration.dispatchCommand({ type: "thread.turn.start", @@ -3376,11 +3615,12 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftInteractionMode, setThreadError, selectedModel, + environmentId, ], ); const onImplementPlanInNewThread = useCallback(async () => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if ( !api || !activeThread || @@ -3452,17 +3692,20 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }) .then(() => { - return waitForStartedServerThread(nextThreadId); + return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); }) .then(() => { // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; return navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, + to: "/$environmentId/$threadId", + params: { + environmentId: activeThread.environmentId, + threadId: nextThreadId, + }, }); }) - .catch(async (err) => { + .catch(async (err: unknown) => { await api.orchestration .dispatchCommand({ type: "thread.delete", @@ -3494,6 +3737,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, selectedProviderModels, selectedModel, + environmentId, ]); const onProviderModelSelect = useCallback( @@ -3514,7 +3758,10 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: resolvedProvider, model: resolvedModel, }; - setComposerDraftModelSelection(activeThread.id, nextModelSelection); + setComposerDraftModelSelection( + scopeThreadRef(activeThread.environmentId, activeThread.id), + nextModelSelection, + ); setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); }, @@ -3546,7 +3793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, - threadId, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], @@ -3555,7 +3803,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, - threadId, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], @@ -3565,11 +3814,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { - setDraftThreadContext(threadId, { envMode: mode }); + setDraftThreadContext(composerDraftTarget, { envMode: mode }); } scheduleComposerFocus(); }, - [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], + [composerDraftTarget, isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext], ); const applyPromptReplacement = useCallback( @@ -3766,7 +4015,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(nextPrompt); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { setComposerDraftTerminalContexts( - threadId, + composerDraftTarget, syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), ); } @@ -3781,8 +4030,8 @@ export default function ChatView({ threadId }: ChatViewProps) { composerTerminalContexts, onChangeActivePendingUserInputCustomAnswer, setPrompt, + composerDraftTarget, setComposerDraftTerminalContexts, - threadId, ], ); @@ -3835,9 +4084,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { + if (!isServerThread) { + return; + } void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath @@ -3846,7 +4101,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }); }, - [navigate, threadId], + [environmentId, isServerThread, navigate, threadId], ); const onRevertUserMessage = (messageId: MessageId) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); @@ -3892,6 +4147,7 @@ export default function ChatView({ threadId }: ChatViewProps) { )} >
@@ -4370,7 +4627,9 @@ export default function ChatView({ threadId }: ChatViewProps) { {isGitRepo && ( {/* end horizontal flex container */} - {mountedTerminalThreadIds.map((mountedThreadId) => ( - - ))} + {mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { + const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); + if (!mountedThreadRef) { + return []; + } + return [ + , + ]; + })} {expandedImage && expandedImageItem && (
(params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + select: (params) => resolveThreadRouteRef(params), }); const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const diffOpen = diffSearch.diff === "1"; - const activeThreadId = routeThreadId; + const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useStore( - useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]), + useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => - activeProjectId ? store.projectById[activeProjectId] : undefined, + activeThread && activeProjectId + ? selectProjectByRef(store, { + environmentId: activeThread.environmentId, + projectId: activeProjectId, + }) + : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useGitStatus(activeCwd ?? null); + const gitStatusQuery = useGitStatus({ + environmentId: activeThread?.environmentId ?? null, + cwd: activeCwd ?? null, + }); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); @@ -263,6 +273,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, [orderedTurnDiffSummaries, selectedTurn]); const activeCheckpointDiffQuery = useQuery( checkpointDiffQueryOptions({ + environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, toTurnCount: activeCheckpointRange?.toTurnCount ?? null, @@ -322,7 +333,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const openDiffFileInEditor = useCallback( (filePath: string) => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api) return; const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; void openInPreferredEditor(api, targetPath).catch((error) => { @@ -335,8 +346,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectTurn = (turnId: TurnId) => { if (!activeThread) return; void navigate({ - to: "/$threadId", - params: { threadId: activeThread.id }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1", diffTurnId: turnId }; @@ -346,8 +357,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectWholeConversation = () => { if (!activeThread) return; void navigate({ - to: "/$threadId", - params: { threadId: activeThread.id }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1" }; diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index ffdb01e9d5..1a1bd714f3 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -1,3 +1,4 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; import { ThreadId } from "@t3tools/contracts"; import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -5,10 +6,24 @@ import { render } from "vitest-browser-react"; const THREAD_A = ThreadId.makeUnsafe("thread-a"); const THREAD_B = ThreadId.makeUnsafe("thread-b"); +const ENVIRONMENT_ID = "environment-local" as never; const GIT_CWD = "/repo/project"; const BRANCH_NAME = "feature/toast-scope"; +function createDeferredPromise() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + + return { promise, resolve, reject }; +} + const { + activeRunStackedActionDeferredRef, invalidateGitQueriesSpy, refreshGitStatusSpy, runStackedActionMutateAsyncSpy, @@ -18,9 +33,10 @@ const { toastPromiseSpy, toastUpdateSpy, } = vi.hoisted(() => ({ + activeRunStackedActionDeferredRef: { current: createDeferredPromise() }, invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), - runStackedActionMutateAsyncSpy: vi.fn(() => new Promise(() => undefined)), + runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), setThreadBranchSpy: vi.fn(), toastAddSpy: vi.fn(() => "toast-1"), toastCloseSpy: vi.fn(), @@ -28,12 +44,8 @@ const { toastUpdateSpy: vi.fn(), })); -vi.mock("@tanstack/react-query", async () => { - const actual = - await vi.importActual("@tanstack/react-query"); - +vi.mock("@tanstack/react-query", () => { return { - ...actual, useIsMutating: vi.fn(() => 0), useMutation: vi.fn((options: { __kind?: string }) => { if (options.__kind === "run-stacked-action") { @@ -56,27 +68,7 @@ vi.mock("@tanstack/react-query", async () => { isPending: false, }; }), - useQuery: vi.fn((options: { queryKey?: string[] }) => { - if (options.queryKey?.[0] === "git-branches") { - return { - data: { - isRepo: true, - hasOriginRemote: true, - branches: [ - { - name: BRANCH_NAME, - current: true, - isDefault: false, - worktreePath: null, - }, - ], - }, - error: null, - }; - } - - return { data: null, error: null }; - }), + useQuery: vi.fn(() => ({ data: null, error: null })), useQueryClient: vi.fn(() => ({})), }; }); @@ -123,27 +115,44 @@ vi.mock("~/lib/gitStatusState", () => ({ })), })); -vi.mock("~/lib/utils", async () => { - const actual = await vi.importActual("~/lib/utils"); - - return { - ...actual, - newCommandId: vi.fn(() => "command-1"), - randomUUID: vi.fn(() => "action-1"), - }; -}); - -vi.mock("~/nativeApi", () => ({ - readNativeApi: vi.fn(() => null), +vi.mock("~/localApi", () => ({ + readLocalApi: vi.fn(() => null), })); vi.mock("~/store", () => ({ + selectEnvironmentState: ( + state: { environmentStateById: Record }, + environmentId: string | null, + ) => { + if (!environmentId) { + throw new Error("Missing environment id"); + } + const environmentState = state.environmentStateById[environmentId]; + if (!environmentState) { + throw new Error(`Unknown environment: ${environmentId}`); + } + return environmentState; + }, useStore: (selector: (state: unknown) => unknown) => selector({ setThreadBranch: setThreadBranchSpy, - threadShellById: { - [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, - [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, + environmentStateById: { + [ENVIRONMENT_ID]: { + threadShellById: { + [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, + [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, + }, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + }, }, }), })); @@ -168,7 +177,10 @@ function Harness() { - + ); } @@ -177,6 +189,7 @@ describe("GitActionsControl thread-scoped progress toast", () => { afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); + activeRunStackedActionDeferredRef.current = createDeferredPromise(); document.body.innerHTML = ""; }); @@ -231,6 +244,9 @@ describe("GitActionsControl thread-scoped progress toast", () => { }), ); } finally { + activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup")); + await Promise.resolve(); + vi.useRealTimers(); await screen.unmount(); host.remove(); } @@ -248,9 +264,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { const host = document.createElement("div"); document.body.append(host); - const screen = await render(, { - container: host, - }); + const screen = await render( + , + { + container: host, + }, + ); try { window.dispatchEvent(new Event("focus")); @@ -264,11 +286,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { await vi.advanceTimersByTimeAsync(1); expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); - expect(refreshGitStatusSpy).toHaveBeenCalledWith(GIT_CWD); + expect(refreshGitStatusSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + cwd: GIT_CWD, + }); } finally { if (originalVisibilityState) { Object.defineProperty(document, "visibilityState", originalVisibilityState); } + vi.useRealTimers(); await screen.unmount(); host.remove(); } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2c9222ee36..06bd21cb20 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,9 +1,9 @@ +import { type ScopedThreadRef } from "@t3tools/contracts"; import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, GitStatusResult, - ThreadId, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; @@ -49,12 +49,14 @@ import { import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; -import { readNativeApi } from "~/nativeApi"; +import { readEnvironmentApi } from "~/environmentApi"; +import { readLocalApi } from "~/localApi"; import { useStore } from "~/store"; +import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; - activeThreadId: ThreadId | null; + activeThreadRef: ScopedThreadRef | null; } interface PendingDefaultBranchAction { @@ -206,14 +208,18 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { +export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) { + const activeThreadId = activeThreadRef?.threadId ?? null; + const activeEnvironmentId = activeThreadRef?.environmentId ?? null; const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], ); - const activeServerThread = useStore((store) => - activeThreadId ? store.threadShellById[activeThreadId] : undefined, + const activeServerThreadSelector = useMemo( + () => createThreadSelectorByRef(activeThreadRef), + [activeThreadRef], ); + const activeServerThread = useStore(activeServerThreadSelector); const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); @@ -246,7 +252,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions } const worktreePath = activeServerThread.worktreePath; - const api = readNativeApi(); + const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; if (api) { void api.orchestration .dispatchCommand({ @@ -261,7 +267,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions setThreadBranch(activeThreadId, branch, worktreePath); }, - [activeServerThread, activeThreadId, setThreadBranch], + [activeEnvironmentId, activeServerThread, activeThreadId, setThreadBranch], ); const syncThreadBranchAfterGitAction = useCallback( @@ -276,7 +282,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [persistThreadBranchSync], ); - const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd); + const { data: gitStatus = null, error: gitStatusError } = useGitStatus({ + environmentId: activeEnvironmentId, + cwd: gitCwd, + }); // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; @@ -287,19 +296,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; - const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); + const initMutation = useMutation( + gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), + ); const runImmediateGitActionMutation = useMutation( gitRunStackedActionMutationOptions({ + environmentId: activeEnvironmentId, cwd: gitCwd, queryClient, }), ); - const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); + const pullMutation = useMutation( + gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), + ); const isRunStackedActionRunning = - useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; - const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; + useIsMutating({ + mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd), + }) > 0; + const isPullRunning = + useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; useEffect(() => { @@ -372,7 +389,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions } refreshTimeout = window.setTimeout(() => { refreshTimeout = null; - void refreshGitStatus(gitCwd).catch(() => undefined); + void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( + () => undefined, + ); }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); }; const handleVisibilityChange = () => { @@ -391,10 +410,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [gitCwd]); + }, [activeEnvironmentId, gitCwd]); const openExistingPr = useCallback(async () => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api) { toastManager.add({ type: "error", @@ -412,7 +431,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); return; } - void api.shell.openExternal(prUrl).catch((err) => { + void api.shell.openExternal(prUrl).catch((err: unknown) => { toastManager.add({ type: "error", title: "Unable to open PR link", @@ -601,7 +620,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions toastActionProps = { children: toastCta.label, onClick: () => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api) return; closeResultToast(); void api.shell.openExternal(toastCta.url); @@ -760,7 +779,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const openChangedFileInEditor = useCallback( (filePath: string) => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api || !gitCwd) { toastManager.add({ type: "error", @@ -836,7 +855,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions { if (open) { - void refreshGitStatus(gitCwd).catch(() => undefined); + void refreshGitStatus({ + environmentId: activeEnvironmentId, + cwd: gitCwd, + }).catch(() => undefined); } }} > diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 1ee13f460f..6ec450d662 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; -import { __resetNativeApiForTests } from "../nativeApi"; +import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; @@ -34,6 +35,7 @@ vi.mock("../lib/gitStatusState", () => ({ const THREAD_ID = "thread-kb-toast-test" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); const NOW_ISO = "2026-03-04T12:00:00.000Z"; interface TestFixture { @@ -49,6 +51,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: LOCAL_ENVIRONMENT_ID, + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -155,6 +164,13 @@ function buildFixture(): TestFixture { snapshot: createMinimalSnapshot(), serverConfig: createBaseServerConfig(), welcome: { + environment: { + environmentId: LOCAL_ENVIRONMENT_ID, + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", projectName: "Project", bootstrapProjectId: PROJECT_ID, @@ -286,7 +302,9 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { host.style.overflow = "hidden"; document.body.append(host); - const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }), + ); const screen = await render( @@ -347,32 +365,17 @@ describe("Keybindings update toast", () => { return []; }, }); - await __resetNativeApiForTests(); + await __resetLocalApiForTests(); localStorage.clear(); document.body.innerHTML = ""; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, }); useStore.setState({ - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: false, + activeEnvironmentId: null, + environmentStateById: {}, }); }); diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 01341dc803..134ca2e6f3 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,4 +1,5 @@ import { memo, useState, useCallback } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; @@ -24,7 +25,7 @@ import { stripDisplayedPlanMarkdown, } from "../proposedPlan"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { readNativeApi } from "~/nativeApi"; +import { readEnvironmentApi } from "~/environmentApi"; import { toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -53,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode { interface PlanSidebarProps { activePlan: ActivePlanState | null; activeProposedPlan: LatestProposedPlanState | null; + environmentId: EnvironmentId; markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; @@ -62,6 +64,7 @@ interface PlanSidebarProps { const PlanSidebar = memo(function PlanSidebar({ activePlan, activeProposedPlan, + environmentId, markdownCwd, workspaceRoot, timestampFormat, @@ -87,7 +90,7 @@ const PlanSidebar = memo(function PlanSidebar({ }, [planMarkdown]); const handleSaveToWorkspace = useCallback(() => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !workspaceRoot || !planMarkdown) return; const filename = buildProposedPlanMarkdownFilename(planMarkdown); setIsSavingToWorkspace(true); @@ -115,7 +118,7 @@ const PlanSidebar = memo(function PlanSidebar({ () => setIsSavingToWorkspace(false), () => setIsSavingToWorkspace(false), ); - }, [planMarkdown, workspaceRoot]); + }, [environmentId, planMarkdown, workspaceRoot]); return (
diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 8fa899343e..6c134f95a0 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,4 +1,4 @@ -import type { GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner"; interface PullRequestThreadDialogProps { open: boolean; + environmentId: EnvironmentId; threadId: ThreadId; cwd: string | null; initialReference: string | null; @@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps { export function PullRequestThreadDialog({ open, + environmentId, threadId, cwd, initialReference, @@ -72,6 +74,7 @@ export function PullRequestThreadDialog({ const parsedDebouncedReference = parsePullRequestReference(debouncedReference); const resolvePullRequestQuery = useQuery( gitResolvePullRequestQueryOptions({ + environmentId, cwd, reference: open ? parsedDebouncedReference : null, }), @@ -83,13 +86,14 @@ export function PullRequestThreadDialog({ const cached = queryClient.getQueryData([ "git", "pull-request", + environmentId, cwd, parsedReference, ]); return cached?.pullRequest ?? null; - }, [cwd, parsedReference, queryClient]); + }, [cwd, environmentId, parsedReference, queryClient]); const preparePullRequestThreadMutation = useMutation( - gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }), + gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }), ); const liveResolvedPullRequest = diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4f4468279..f9e5561a50 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,7 +20,7 @@ import { sortThreadsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; -import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -28,6 +28,8 @@ import { type Thread, } from "../types"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; @@ -625,6 +627,7 @@ function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { id: ProjectId.makeUnsafe("project-1"), + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -642,6 +645,7 @@ function makeProject(overrides: Partial = {}): Project { function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 74cdf6eefe..9181434547 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -12,20 +12,7 @@ import { } from "lucide-react"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type Dispatch, - type KeyboardEvent, - type MouseEvent, - type MutableRefObject, - type PointerEvent, - type ReactNode, - type SetStateAction, -} from "react"; +import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { DndContext, @@ -45,11 +32,20 @@ import { CSS } from "@dnd-kit/utilities"; import { DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, + type EnvironmentId, ProjectId, + type ScopedThreadRef, + type ThreadEnvMode, ThreadId, type GitStatusResult, } from "@t3tools/contracts"; -import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; +import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, @@ -58,7 +54,13 @@ import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; -import { useStore } from "../store"; +import { + selectProjectsAcrossEnvironments, + selectSidebarThreadsForProjectRef, + selectSidebarThreadsAcrossEnvironments, + selectThreadByRef, + useStore, +} from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { @@ -70,11 +72,16 @@ import { threadTraversalDirectionFromCommand, } from "../keybindings"; import { useGitStatus } from "../lib/gitStatusState"; -import { readNativeApi } from "../nativeApi"; +import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; +import { + buildThreadRouteParams, + resolveThreadRouteRef, + resolveThreadRouteTarget, +} from "../threadRoutes"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -109,8 +116,6 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { - getVisibleSidebarThreadIds, - getVisibleThreadsForProject, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, @@ -123,12 +128,14 @@ import { sortProjectsForSidebar, sortThreadsForSidebar, useThreadJumpHintVisibility, + ThreadStatusPill, } from "./Sidebar.logic"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import type { Project } from "../types"; +import type { Project, SidebarThreadSummary } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -143,9 +150,58 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; +const EMPTY_THREAD_JUMP_LABELS = new Map(); + +function threadJumpLabelMapsEqual( + left: ReadonlyMap, + right: ReadonlyMap, +): boolean { + if (left === right) { + return true; + } + if (left.size !== right.size) { + return false; + } + for (const [key, value] of left) { + if (right.get(key) !== value) { + return false; + } + } + return true; +} + +function buildThreadJumpLabelMap(input: { + keybindings: ReturnType; + platform: string; + terminalOpen: boolean; + threadJumpCommandByKey: ReadonlyMap< + string, + NonNullable> + >; +}): ReadonlyMap { + if (input.threadJumpCommandByKey.size === 0) { + return EMPTY_THREAD_JUMP_LABELS; + } + + const shortcutLabelOptions = { + platform: input.platform, + context: { + terminalFocus: false, + terminalOpen: input.terminalOpen, + }, + } as const; + const mapping = new Map(); + for (const [threadKey, command] of input.threadJumpCommandByKey) { + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(threadKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; +} type SidebarProjectSnapshot = Project & { - expanded: boolean; + projectKey: string; }; interface TerminalStatusIndicator { label: "Terminal process running"; @@ -166,7 +222,7 @@ function ThreadStatusLabel({ status, compact = false, }: { - status: NonNullable>; + status: ThreadStatusPill; compact?: boolean; }) { if (compact) { @@ -255,57 +311,81 @@ function resolveThreadPr( } interface SidebarThreadRowProps { - threadId: ThreadId; + thread: SidebarThreadSummary; projectCwd: string | null; - orderedProjectThreadIds: readonly ThreadId[]; - routeThreadId: ThreadId | null; - selectedThreadIds: ReadonlySet; - showThreadJumpHints: boolean; + orderedProjectThreadKeys: readonly string[]; + isActive: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; - renamingThreadId: ThreadId | null; + renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; - onRenamingInputMount: (element: HTMLInputElement | null) => void; - hasRenameCommitted: () => boolean; - markRenameCommitted: () => void; - confirmingArchiveThreadId: ThreadId | null; - setConfirmingArchiveThreadId: Dispatch>; - confirmArchiveButtonRefs: MutableRefObject>; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; handleThreadClick: ( - event: MouseEvent, - threadId: ThreadId, - orderedProjectThreadIds: readonly ThreadId[], + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], ) => void; - navigateToThread: (threadId: ThreadId) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( - threadId: ThreadId, + threadRef: ScopedThreadRef, position: { x: number; y: number }, ) => Promise; clearSelection: () => void; - commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; cancelRename: () => void; - attemptArchiveThread: (threadId: ThreadId) => Promise; - openPrLink: (event: MouseEvent, prUrl: string) => void; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: (event: React.MouseEvent, prUrl: string) => void; } -function SidebarThreadRow(props: SidebarThreadRowProps) { - const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); +const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const { + orderedProjectThreadKeys, + isActive, + jumpLabel, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + thread, + } = props; + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); const runningTerminalIds = useTerminalStateStore( (state) => - selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, ); - const gitCwd = thread?.worktreePath ?? props.projectCwd; - const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); - - if (!thread) { - return null; - } - - const isActive = props.routeThreadId === thread.id; - const isSelected = props.selectedThreadIds.has(thread.id); + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useGitStatus({ + environmentId: thread.environmentId, + cwd: thread.branch != null ? gitCwd : null, + }); const isHighlighted = isActive || isSelected; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; @@ -318,32 +398,173 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; + const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" : !isThreadRunning ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" : "pointer-events-none"; + const clearConfirmingArchive = useCallback(() => { + setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); + }, [setConfirmingArchiveThreadKey, threadKey]); + const handleMouseLeave = useCallback(() => { + clearConfirmingArchive(); + }, [clearConfirmingArchive]); + const handleBlurCapture = useCallback( + (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + clearConfirmingArchive(); + }); + }, + [clearConfirmingArchive], + ); + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + handleThreadClick(event, threadRef, orderedProjectThreadKeys); + }, + [handleThreadClick, orderedProjectThreadKeys, threadRef], + ); + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }, + [navigateToThread, threadRef], + ); + const handleRowContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (hasSelection && isSelected) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + return; + } + + if (hasSelection) { + clearSelection(); + } + void handleThreadContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }); + }, + [ + clearSelection, + handleMultiSelectContextMenu, + handleThreadContextMenu, + hasSelection, + isSelected, + threadRef, + ], + ); + const handlePrClick = useCallback( + (event: React.MouseEvent) => { + if (!prStatus) return; + openPrLink(event, prStatus.url); + }, + [openPrLink, prStatus], + ); + const handleRenameInputRef = useCallback( + (element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + } + }, + [renamingInputRef], + ); + const handleRenameInputChange = useCallback( + (event: React.ChangeEvent) => { + setRenamingTitle(event.target.value); + }, + [setRenamingTitle], + ); + const handleRenameInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }, + [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], + ); + const handleRenameInputBlur = useCallback(() => { + if (!renamingCommittedRef.current) { + void commitRename(threadRef, renamingTitle, thread.title); + } + }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + const handleRenameInputClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + const handleConfirmArchiveRef = useCallback( + (element: HTMLButtonElement | null) => { + if (element) { + confirmArchiveButtonRefs.current.set(threadKey, element); + } else { + confirmArchiveButtonRefs.current.delete(threadKey); + } + }, + [confirmArchiveButtonRefs, threadKey], + ); + const stopPropagationOnPointerDown = useCallback( + (event: React.PointerEvent) => { + event.stopPropagation(); + }, + [], + ); + const handleConfirmArchiveClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearConfirmingArchive(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, clearConfirmingArchive, threadRef], + ); + const handleStartArchiveConfirmation = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setConfirmingArchiveThreadKey(threadKey); + requestAnimationFrame(() => { + confirmArchiveButtonRefs.current.get(threadKey)?.focus(); + }); + }, + [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], + ); + const handleArchiveImmediateClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, threadRef], + ); + const rowButtonRender = useMemo(() =>
, []); return ( { - props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }} - onBlurCapture={(event) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }); - }} + onMouseLeave={handleMouseLeave} + onBlurCapture={handleBlurCapture} > } + render={rowButtonRender} size="sm" isActive={isActive} data-testid={`thread-row-${thread.id}`} @@ -351,31 +572,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { isActive, isSelected, })} relative isolate`} - onClick={(event) => { - props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - props.navigateToThread(thread.id); - }} - onContextMenu={(event) => { - event.preventDefault(); - if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) { - void props.handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (props.selectedThreadIds.size > 0) { - props.clearSelection(); - } - void props.handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - } - }} + onClick={handleRowClick} + onKeyDown={handleRowKeyDown} + onContextMenu={handleRowContextMenu} >
{prStatus && ( @@ -386,9 +585,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { type="button" aria-label={prStatus.tooltip} className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} - onClick={(event) => { - props.openPrLink(event, prStatus.url); - }} + onClick={handlePrClick} > @@ -398,30 +595,15 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { )} {threadStatus && } - {props.renamingThreadId === thread.id ? ( + {renamingThreadKey === threadKey ? ( props.setRenamingTitle(event.target.value)} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - props.markRenameCommitted(); - void props.commitRename(thread.id, props.renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - props.markRenameCommitted(); - props.cancelRename(); - } - }} - onBlur={() => { - if (!props.hasRenameCommitted()) { - void props.commitRename(thread.id, props.renamingTitle, thread.title); - } - }} - onClick={(event) => event.stopPropagation()} + value={renamingTitle} + onChange={handleRenameInputChange} + onKeyDown={handleRenameInputKeyDown} + onBlur={handleRenameInputBlur} + onClick={handleRenameInputClick} /> ) : ( {thread.title} @@ -441,34 +623,19 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
{isConfirmingArchive ? ( ) : !isThreadRunning ? ( - props.appSettingsConfirmThreadArchive ? ( + appSettingsConfirmThreadArchive ? (
@@ -502,14 +660,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { data-testid={`thread-archive-${thread.id}`} aria-label={`Archive ${thread.title}`} 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" - onPointerDown={(event) => { - event.stopPropagation(); - }} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - void props.attemptArchiveThread(thread.id); - }} + onPointerDown={stopPropagationOnPointerDown} + onClick={handleArchiveImmediateClick} > @@ -521,12 +673,12 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { ) ) : null} - {props.showThreadJumpHints && props.jumpLabel ? ( + {jumpLabel ? ( - {props.jumpLabel} + {jumpLabel} ) : ( ); +}); + +interface SidebarProjectThreadListProps { + projectKey: string; + projectExpanded: boolean; + hasOverflowingThreads: boolean; + hiddenThreadStatus: ThreadStatusPill | null; + orderedProjectThreadKeys: readonly string[]; + renderedThreads: readonly SidebarThreadSummary[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; + isThreadListExpanded: boolean; + projectCwd: string; + activeRouteThreadKey: string | null; + threadJumpLabelByKey: ReadonlyMap; + appSettingsConfirmThreadArchive: boolean; + renamingThreadKey: string | null; + renamingTitle: string; + setRenamingTitle: (title: string) => void; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + handleThreadClick: ( + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], + ) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + handleThreadContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + ) => Promise; + clearSelection: () => void; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; + cancelRename: () => void; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: (event: React.MouseEvent, prUrl: string) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; } -function T3Wordmark() { - return ( - - - - ); -} - -type SortableProjectHandleProps = Pick< - ReturnType, - "attributes" | "listeners" | "setActivatorNodeRef" ->; +const SidebarProjectThreadList = memo(function SidebarProjectThreadList( + props: SidebarProjectThreadListProps, +) { + const { + projectKey, + projectExpanded, + hasOverflowingThreads, + hiddenThreadStatus, + orderedProjectThreadKeys, + renderedThreads, + showEmptyThreadState, + shouldShowThreadPanel, + isThreadListExpanded, + projectCwd, + activeRouteThreadKey, + threadJumpLabelByKey, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + attachThreadListAutoAnimateRef, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + expandThreadListForProject, + collapseThreadListForProject, + } = props; + const showMoreButtonRender = useMemo(() => + + ) : null} + + + ) : null} + +
+ + Projects + +
+ + + + } + > + + + + {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} + + +
+
+ {shouldShowProjectPathEntry && ( +
+ {isElectron && ( + + )} +
+ + +
+ {addProjectError && ( +

+ {addProjectError} +

+ )} +
+ )} + + {isManualProjectSorting ? ( + + + project.projectKey)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => ( + + )} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + ))} + + )} + + {projectsLength === 0 && !shouldShowProjectPathEntry && ( +
+ No projects yet +
+ )} +
+ + ); +}); + +export default function Sidebar() { + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const navigate = useNavigate(); + const pathname = useLocation({ select: (loc) => loc.pathname }); + const isOnSettings = pathname.startsWith("/settings"); + const appSettings = useSettings(); + const { updateSettings } = useUpdateSettings(); + const { handleNewThread } = useHandleNewThread(); + const { archiveThread, deleteThread } = useThreadActions(); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const keybindings = useServerKeybindings(); + const [addingProject, setAddingProject] = useState(false); + const [newCwd, setNewCwd] = useState(""); + const [isPickingFolder, setIsPickingFolder] = useState(false); + const [isAddingProject, setIsAddingProject] = useState(false); + const [addProjectError, setAddProjectError] = useState(null); + const addProjectInputRef = useRef(null); + const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< + ReadonlySet + >(() => new Set()); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const dragInProgressRef = useRef(false); + const suppressProjectClickAfterDragRef = useRef(false); + const suppressProjectClickForContextMenuRef = useRef(false); + const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); + const platform = navigator.platform; + const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; + const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + }); + }, [projectOrder, projects]); + const sidebarProjects = useMemo( + () => + orderedProjects.map((project) => ({ + ...project, + projectKey: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + })), + [orderedProjects], + ); + const sidebarProjectByKey = useMemo( + () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + [sidebarProjects], + ); + const sidebarThreadByKey = useMemo( + () => + new Map( + sidebarThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [sidebarThreads], + ); + const activeRouteProjectKey = useMemo(() => { + if (!routeThreadKey) { + return null; + } + const activeThread = sidebarThreadByKey.get(routeThreadKey); + return activeThread + ? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)) + : null; + }, [routeThreadKey, sidebarThreadByKey]); + const threadsByProjectKey = useMemo(() => { + const next = new Map(); + for (const thread of sidebarThreads) { + const projectKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const existing = next.get(projectKey); + if (existing) { + existing.push(thread); + } else { + next.set(projectKey, [thread]); } - if (clicked !== "delete") return; + } + return next; + }, [sidebarThreads]); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }), + [routeThreadRef], + ); + const newThreadShortcutLabelOptions = useMemo( + () => ({ + platform, + context: { + terminalFocus: false, + terminalOpen: false, + }, + }), + [platform], + ); + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? + shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); + const focusMostRecentThreadForProject = useCallback( + (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { + const projectKey = scopedProjectKey( + scopeProjectRef(projectRef.environmentId, projectRef.projectId), + ); + const latestThread = sortThreadsForSidebar( + (threadsByProjectKey.get(projectKey) ?? []).filter((thread) => thread.archivedAt === null), + appSettings.sidebarThreadSortOrder, + )[0]; + if (!latestThread) return; - const projectThreadIds = threadIdsByProjectId[projectId] ?? []; - if (projectThreadIds.length > 0) { - toastManager.add({ - type: "warning", - title: "Project is not empty", - description: "Delete all threads in this project before removing it.", + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), + }); + }, + [appSettings.sidebarThreadSortOrder, navigate, threadsByProjectKey], + ); + + const addProjectFromPath = useCallback( + async (rawCwd: string) => { + const cwd = rawCwd.trim(); + if (!cwd || isAddingProject) return; + const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; + if (!api) return; + + setIsAddingProject(true); + const finishAddingProject = () => { + setIsAddingProject(false); + setNewCwd(""); + setAddProjectError(null); + setAddingProject(false); + }; + + const existing = projects.find((project) => project.cwd === cwd); + if (existing) { + focusMostRecentThreadForProject({ + environmentId: existing.environmentId, + projectId: existing.id, }); + finishAddingProject(); return; } - const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); - if (!confirmed) return; - + const projectId = newProjectId(); + const createdAt = new Date().toISOString(); + const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; try { - const projectDraftThread = getDraftThreadByProjectId(projectId); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.threadId); - } - clearProjectDraftThreadId(projectId); await api.orchestration.dispatchCommand({ - type: "project.delete", + type: "project.create", commandId: newCommandId(), projectId, + title, + workspaceRoot: cwd, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + createdAt, }); + if (activeEnvironmentId !== null) { + await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { + envMode: appSettings.defaultThreadEnvMode, + }).catch(() => undefined); + } } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { projectId, error }); - toastManager.add({ - type: "error", - title: `Failed to remove "${project.name}"`, - description: message, - }); + const description = + error instanceof Error ? error.message : "An error occurred while adding the project."; + setIsAddingProject(false); + if (shouldBrowseForProjectImmediately) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description, + }); + } else { + setAddProjectError(description); + } + return; } + finishAddingProject(); }, [ - clearComposerDraftForThread, - clearProjectDraftThreadId, - copyPathToClipboard, - getDraftThreadByProjectId, + focusMostRecentThreadForProject, + activeEnvironmentId, + handleNewThread, + isAddingProject, projects, - threadIdsByProjectId, + shouldBrowseForProjectImmediately, + appSettings.defaultThreadEnvMode, ], ); + const handleAddProject = () => { + void addProjectFromPath(newCwd); + }; + + const canAddProject = newCwd.trim().length > 0 && !isAddingProject; + + const handlePickFolder = async () => { + const api = readLocalApi(); + if (!api || isPickingFolder) return; + setIsPickingFolder(true); + let pickedPath: string | null = null; + try { + pickedPath = await api.dialogs.pickFolder(); + } catch { + // Ignore picker failures and leave the current thread selection unchanged. + } + if (pickedPath) { + await addProjectFromPath(pickedPath); + } else if (!shouldBrowseForProjectImmediately) { + addProjectInputRef.current?.focus(); + } + setIsPickingFolder(false); + }; + + const handleStartAddProject = () => { + setAddProjectError(null); + if (shouldBrowseForProjectImmediately) { + void handlePickFolder(); + return; + } + setAddingProject((prev) => !prev); + }; + + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, navigate, setSelectionAnchor], + ); + const projectDnDSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 }, @@ -1371,10 +2508,10 @@ export default function Sidebar() { dragInProgressRef.current = false; const { active, over } = event; if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.id === active.id); - const overProject = sidebarProjects.find((project) => project.id === over.id); + const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); + const overProject = sidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; - reorderProjects(activeProject.id, overProject.id); + reorderProjects(activeProject.projectKey, overProject.projectKey); }, [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], ); @@ -1412,148 +2549,117 @@ export default function Sidebar() { animatedThreadListsRef.current.add(node); }, []); - const handleProjectTitlePointerDownCapture = useCallback( - (event: PointerEvent) => { - suppressProjectClickForContextMenuRef.current = false; - if ( - isContextMenuPointerDown({ - button: event.button, - ctrlKey: event.ctrlKey, - isMac: isMacPlatform(navigator.platform), - }) - ) { - // Keep context-menu gestures from arming the sortable drag sensor. - event.stopPropagation(); - } - - suppressProjectClickAfterDragRef.current = false; - }, - [], - ); - const visibleThreads = useMemo( () => sidebarThreads.filter((thread) => thread.archivedAt === null), [sidebarThreads], ); - const sortedProjects = useMemo( - () => - sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder), - [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads], - ); + const sortedProjects = useMemo(() => { + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = visibleThreads.map((thread) => ({ + ...thread, + projectId: scopedProjectKey( + scopeProjectRef(thread.environmentId, thread.projectId), + ) as ProjectId, + })); + return sortProjectsForSidebar( + sortableProjects, + sortableThreads, + appSettings.sidebarProjectSortOrder, + ).flatMap((project) => { + const resolvedProject = sidebarProjectByKey.get(project.id); + return resolvedProject ? [resolvedProject] : []; + }); + }, [appSettings.sidebarProjectSortOrder, sidebarProjectByKey, sidebarProjects, visibleThreads]); const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; - const renderedProjects = useMemo( + const visibleSidebarThreadKeys = useMemo( () => - sortedProjects.map((project) => { - const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) => - resolveThreadStatusPill({ - thread: { - ...thread, - lastVisitedAt: threadLastVisitedAtById[thread.id], - }, - }); + sortedProjects.flatMap((project) => { const projectThreads = sortThreadsForSidebar( - (threadIdsByProjectId[project.id] ?? []) - .map((threadId) => sidebarThreadsById[threadId]) - .filter((thread): thread is NonNullable => thread !== undefined) - .filter((thread) => thread.archivedAt === null), + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), appSettings.sidebarThreadSortOrder, ); - const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => resolveProjectThreadStatus(thread)), - ); - const activeThreadId = routeThreadId ?? undefined; - const isThreadListExpanded = expandedThreadListsByProject.has(project.id); + const projectExpanded = projectExpandedById[project.projectKey] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; const pinnedCollapsedThread = - !project.expanded && activeThreadId - ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) + !projectExpanded && activeThreadKey + ? (projectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeThreadKey, + ) ?? null) : null; - const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; - const { - hasHiddenThreads, - hiddenThreads, - visibleThreads: visibleProjectThreads, - } = getVisibleThreadsForProject({ - threads: projectThreads, - activeThreadId, - isThreadListExpanded, - previewLimit: THREAD_PREVIEW_LIMIT, - }); - const hiddenThreadStatus = resolveProjectStatusIndicator( - hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); + const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const previewThreads = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreads + : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); - const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreadIds = pinnedCollapsedThread - ? [pinnedCollapsedThread.id] - : visibleProjectThreads.map((thread) => thread.id); - const showEmptyThreadState = project.expanded && projectThreads.length === 0; - - return { - hasHiddenThreads, - hiddenThreadStatus, - orderedProjectThreadIds, - project, - projectStatus, - renderedThreadIds, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - }; }), [ appSettings.sidebarThreadSortOrder, expandedThreadListsByProject, - routeThreadId, + projectExpandedById, + routeThreadKey, sortedProjects, - sidebarThreadsById, - threadIdsByProjectId, - threadLastVisitedAtById, + threadsByProjectKey, ], ); - const visibleSidebarThreadIds = useMemo( - () => getVisibleSidebarThreadIds(renderedProjects), - [renderedProjects], - ); - const threadJumpCommandById = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) { + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); if (!jumpCommand) { return mapping; } - mapping.set(threadId, jumpCommand); + mapping.set(threadKey, jumpCommand); } return mapping; - }, [visibleSidebarThreadIds]); - const threadJumpThreadIds = useMemo( - () => [...threadJumpCommandById.keys()], - [threadJumpCommandById], - ); - const threadJumpLabelById = useMemo(() => { - const mapping = new Map(); - for (const [threadId, command] of threadJumpCommandById) { - const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions); - if (label) { - mapping.set(threadId, label); - } - } - return mapping; - }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); - const orderedSidebarThreadIds = visibleSidebarThreadIds; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const [threadJumpLabelByKey, setThreadJumpLabelByKey] = + useState>(EMPTY_THREAD_JUMP_LABELS); + const visibleThreadJumpLabelByKey = showThreadJumpHints + ? threadJumpLabelByKey + : EMPTY_THREAD_JUMP_LABELS; + const orderedSidebarThreadKeys = visibleSidebarThreadKeys; useEffect(() => { - const getShortcutContext = () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - }); - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - updateThreadJumpHintsVisibility( - shouldShowThreadJumpHints(event, keybindings, { + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + setThreadJumpLabelByKey((current) => { + if (!shouldShowHints) { + return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; + } + const nextLabelMap = buildThreadJumpLabelMap({ + keybindings, platform, - context: getShortcutContext(), - }), - ); + terminalOpen: shortcutContext.terminalOpen, + threadJumpCommandByKey, + }); + return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; + }); + updateThreadJumpHintsVisibility(shouldShowHints); if (event.defaultPrevented || event.repeat) { return; @@ -1561,22 +2667,26 @@ export default function Sidebar() { const command = resolveShortcutCommand(event, keybindings, { platform, - context: getShortcutContext(), + context: shortcutContext, }); const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { - const targetThreadId = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadIds, - currentThreadId: routeThreadId, + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: orderedSidebarThreadKeys, + currentThreadId: routeThreadKey, direction: traversalDirection, }); - if (!targetThreadId) { + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); - navigateToThread(targetThreadId); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); return; } @@ -1585,26 +2695,45 @@ export default function Sidebar() { return; } - const targetThreadId = threadJumpThreadIds[jumpIndex]; - if (!targetThreadId) { + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); - navigateToThread(targetThreadId); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); }; const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - updateThreadJumpHintsVisibility( - shouldShowThreadJumpHints(event, keybindings, { + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + setThreadJumpLabelByKey((current) => { + if (!shouldShowHints) { + return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; + } + const nextLabelMap = buildThreadJumpLabelMap({ + keybindings, platform, - context: getShortcutContext(), - }), - ); + terminalOpen: shortcutContext.terminalOpen, + threadJumpCommandByKey, + }); + return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; + }); + updateThreadJumpHintsVisibility(shouldShowHints); }; const onWindowBlur = () => { + setThreadJumpLabelByKey((current) => + current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, + ); updateThreadJumpHintsVisibility(false); }; @@ -1618,301 +2747,21 @@ export default function Sidebar() { window.removeEventListener("blur", onWindowBlur); }; }, [ + getCurrentSidebarShortcutContext, keybindings, navigateToThread, - orderedSidebarThreadIds, + orderedSidebarThreadKeys, platform, - routeTerminalOpen, - routeThreadId, - threadJumpThreadIds, + routeThreadKey, + sidebarThreadByKey, + threadJumpCommandByKey, + threadJumpThreadKeys, updateThreadJumpHintsVisibility, ]); - function renderProjectItem( - renderedProject: (typeof renderedProjects)[number], - dragHandleProps: SortableProjectHandleProps | null, - ) { - const { - hasHiddenThreads, - hiddenThreadStatus, - orderedProjectThreadIds, - project, - projectStatus, - renderedThreadIds, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - } = renderedProject; - return ( - <> -
- handleProjectTitleClick(event, project.id)} - onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} - onContextMenu={(event) => { - event.preventDefault(); - suppressProjectClickForContextMenuRef.current = true; - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - {!project.expanded && projectStatus ? ( - - - - } - showOnHover - className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - const seedContext = resolveSidebarNewThreadSeedContext({ - projectId: project.id, - defaultEnvMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), - activeThread: - activeThread && activeThread.projectId === project.id - ? { - projectId: activeThread.projectId, - branch: activeThread.branch, - worktreePath: activeThread.worktreePath, - } - : null, - activeDraftThread: - activeDraftThread && activeDraftThread.projectId === project.id - ? { - projectId: activeDraftThread.projectId, - branch: activeDraftThread.branch, - worktreePath: activeDraftThread.worktreePath, - envMode: activeDraftThread.envMode, - } - : null, - }); - void handleNewThread(project.id, { - ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), - ...(seedContext.worktreePath !== undefined - ? { worktreePath: seedContext.worktreePath } - : {}), - envMode: seedContext.envMode, - }); - }} - > - - - } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - -
- - - {shouldShowThreadPanel && showEmptyThreadState ? ( - -
- No threads yet -
-
- ) : null} - {shouldShowThreadPanel && - renderedThreadIds.map((threadId) => ( - - ))} - - {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - 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" - onClick={() => { - expandThreadListForProject(project.id); - }} - > - - {hiddenThreadStatus && } - Show more - - - - )} - {project.expanded && hasHiddenThreads && isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - 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" - onClick={() => { - collapseThreadListForProject(project.id); - }} - > - Show less - - - )} -
- - ); - } - - const handleProjectTitleClick = useCallback( - (event: MouseEvent, projectId: ProjectId) => { - if (suppressProjectClickForContextMenuRef.current) { - suppressProjectClickForContextMenuRef.current = false; - event.preventDefault(); - event.stopPropagation(); - return; - } - if (dragInProgressRef.current) { - event.preventDefault(); - event.stopPropagation(); - return; - } - if (suppressProjectClickAfterDragRef.current) { - // Consume the synthetic click emitted after a drag release. - suppressProjectClickAfterDragRef.current = false; - event.preventDefault(); - event.stopPropagation(); - return; - } - if (selectedThreadIds.size > 0) { - clearSelection(); - } - toggleProject(projectId); - }, - [clearSelection, selectedThreadIds.size, toggleProject], - ); - - const handleProjectTitleKeyDown = useCallback( - (event: KeyboardEvent, projectId: ProjectId) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (dragInProgressRef.current) { - return; - } - toggleProject(projectId); - }, - [toggleProject], - ); - useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { - if (selectedThreadIds.size === 0) return; + if (selectedThreadCount === 0) return; const target = event.target instanceof HTMLElement ? event.target : null; if (!shouldClearThreadSelectionOnMouseDown(target)) return; clearSelection(); @@ -1922,7 +2771,7 @@ export default function Sidebar() { return () => { window.removeEventListener("mousedown", onMouseDown); }; - }, [clearSelection, selectedThreadIds.size]); + }, [clearSelection, selectedThreadCount]); useEffect(() => { if (!isElectron) return; @@ -1967,10 +2816,6 @@ export default function Sidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); - const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; @@ -2033,246 +2878,82 @@ export default function Sidebar() { } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - const expandThreadListForProject = useCallback((projectId: ProjectId) => { + const expandThreadListForProject = useCallback((projectKey: string) => { setExpandedThreadListsByProject((current) => { - if (current.has(projectId)) return current; + if (current.has(projectKey)) return current; const next = new Set(current); - next.add(projectId); + next.add(projectKey); return next; }); }, []); - const collapseThreadListForProject = useCallback((projectId: ProjectId) => { + const collapseThreadListForProject = useCallback((projectKey: string) => { setExpandedThreadListsByProject((current) => { - if (!current.has(projectId)) return current; + if (!current.has(projectKey)) return current; const next = new Set(current); - next.delete(projectId); + next.delete(projectKey); return next; }); }, []); - const wordmark = ( -
- - - - - - Code - - - {APP_STAGE_LABEL} - - - } - /> - - Version {APP_VERSION} - - -
- ); - return ( <> - {isElectron ? ( - - {wordmark} - - ) : ( - - {wordmark} - - )} + {isOnSettings ? ( ) : ( <> - - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null} - -
- - Projects - -
- { - updateSettings({ sidebarProjectSortOrder: sortOrder }); - }} - onThreadSortOrderChange={(sortOrder) => { - updateSettings({ sidebarThreadSortOrder: sortOrder }); - }} - /> - - - } - > - - - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - - -
-
- {shouldShowProjectPathEntry && ( -
- {isElectron && ( - - )} -
- { - setNewCwd(event.target.value); - setAddProjectError(null); - }} - onKeyDown={(event) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } - }} - autoFocus - /> - -
- {addProjectError && ( -

- {addProjectError} -

- )} -
- )} - - {isManualProjectSorting ? ( - - - renderedProject.project.id)} - strategy={verticalListSortingStrategy} - > - {renderedProjects.map((renderedProject) => ( - - {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)} - - ))} - - - - ) : ( - - {renderedProjects.map((renderedProject) => ( - - {renderProjectItem(renderedProject, null)} - - ))} - - )} - - {projects.length === 0 && !shouldShowProjectPathEntry && ( -
- No projects yet -
- )} -
-
+ - - - - - void navigate({ to: "/settings" })} - > - - Settings - - - - + )} diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx new file mode 100644 index 0000000000..5f01e53af4 --- /dev/null +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -0,0 +1,247 @@ +import "../index.css"; + +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { ThreadId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +const { + terminalConstructorSpy, + terminalDisposeSpy, + fitAddonFitSpy, + fitAddonLoadSpy, + environmentApiById, + readEnvironmentApiMock, + readLocalApiMock, +} = vi.hoisted(() => ({ + terminalConstructorSpy: vi.fn(), + terminalDisposeSpy: vi.fn(), + fitAddonFitSpy: vi.fn(), + fitAddonLoadSpy: vi.fn(), + environmentApiById: new Map } }>(), + readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), + readLocalApiMock: vi.fn< + () => + | { + contextMenu: { show: ReturnType }; + shell: { openExternal: ReturnType }; + } + | undefined + >(() => ({ + contextMenu: { show: vi.fn(async () => null) }, + shell: { openExternal: vi.fn(async () => undefined) }, + })), +})); + +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class MockFitAddon { + fit = fitAddonFitSpy; + }, +})); + +vi.mock("@xterm/xterm", () => ({ + Terminal: class MockTerminal { + cols = 80; + rows = 24; + options: { theme?: unknown } = {}; + buffer = { + active: { + viewportY: 0, + baseY: 0, + getLine: vi.fn(() => null), + }, + }; + + constructor(options: unknown) { + terminalConstructorSpy(options); + } + + loadAddon(addon: unknown) { + fitAddonLoadSpy(addon); + } + + open() {} + + write() {} + + clear() {} + + clearSelection() {} + + focus() {} + + refresh() {} + + scrollToBottom() {} + + hasSelection() { + return false; + } + + getSelection() { + return ""; + } + + getSelectionPosition() { + return null; + } + + attachCustomKeyEventHandler() { + return true; + } + + registerLinkProvider() { + return { dispose: vi.fn() }; + } + + onData() { + return { dispose: vi.fn() }; + } + + onSelectionChange() { + return { dispose: vi.fn() }; + } + + dispose() { + terminalDisposeSpy(); + } + }, +})); + +vi.mock("~/environmentApi", () => ({ + readEnvironmentApi: readEnvironmentApiMock, +})); + +vi.mock("~/localApi", () => ({ + readLocalApi: readLocalApiMock, +})); + +import { TerminalViewport } from "./ThreadTerminalDrawer"; + +const THREAD_ID = ThreadId.makeUnsafe("thread-terminal-browser"); + +function createEnvironmentApi() { + return { + terminal: { + open: vi.fn(async () => ({ + threadId: THREAD_ID, + terminalId: "default", + cwd: "/repo/project", + worktreePath: null, + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: "2026-04-07T00:00:00.000Z", + })), + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + }, + }; +} + +async function mountTerminalViewport(props: { threadRef: ReturnType }) { + const host = document.createElement("div"); + host.style.width = "800px"; + host.style.height = "400px"; + document.body.append(host); + + const screen = await render( + undefined} + onAddTerminalContext={() => undefined} + focusRequestId={0} + autoFocus={false} + resizeEpoch={0} + drawerHeight={320} + />, + { container: host }, + ); + + return { + rerender: async (nextProps: { threadRef: ReturnType }) => { + await screen.rerender( + undefined} + onAddTerminalContext={() => undefined} + focusRequestId={0} + autoFocus={false} + resizeEpoch={0} + drawerHeight={320} + />, + ); + }, + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("TerminalViewport", () => { + afterEach(() => { + environmentApiById.clear(); + readEnvironmentApiMock.mockClear(); + readLocalApiMock.mockClear(); + terminalConstructorSpy.mockClear(); + terminalDisposeSpy.mockClear(); + fitAddonFitSpy.mockClear(); + fitAddonLoadSpy.mockClear(); + }); + + it("does not create a terminal when APIs are unavailable", async () => { + readEnvironmentApiMock.mockReturnValueOnce(undefined); + readLocalApiMock.mockReturnValueOnce(undefined); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + try { + await vi.waitFor(() => { + expect(terminalConstructorSpy).not.toHaveBeenCalled(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("reopens the terminal when the scoped thread reference changes", async () => { + const environmentA = createEnvironmentApi(); + const environmentB = createEnvironmentApi(); + environmentApiById.set("environment-a", environmentA); + environmentApiById.set("environment-b", environmentB); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + try { + await vi.waitFor(() => { + expect(environmentA.terminal.open).toHaveBeenCalledTimes(1); + }); + + await mounted.rerender({ + threadRef: scopeThreadRef("environment-b" as never, THREAD_ID), + }); + + await vi.waitFor(() => { + expect(environmentB.terminal.open).toHaveBeenCalledTimes(1); + }); + expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ffb7c1e4d0..de40cc0bf8 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,6 +1,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; import { + type ScopedThreadRef, type TerminalEvent, type TerminalSessionSnapshot, type ThreadId, @@ -31,7 +32,8 @@ import { MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; -import { readNativeApi } from "~/nativeApi"; +import { readEnvironmentApi } from "~/environmentApi"; +import { readLocalApi } from "~/localApi"; import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; const MIN_DRAWER_HEIGHT = 180; @@ -208,6 +210,7 @@ export function shouldHandleTerminalSelectionMouseUp( } interface TerminalViewportProps { + threadRef: ScopedThreadRef; threadId: ThreadId; terminalId: string; terminalLabel: string; @@ -222,7 +225,8 @@ interface TerminalViewportProps { drawerHeight: number; } -function TerminalViewport({ +export function TerminalViewport({ + threadRef, threadId, terminalId, terminalLabel, @@ -260,6 +264,9 @@ function TerminalViewport({ if (!mount) return; let disposed = false; + const api = readEnvironmentApi(threadRef.environmentId); + const localApi = readLocalApi(); + if (!api || !localApi) return; const fitAddon = new FitAddon(); const terminal = new Terminal({ @@ -277,9 +284,6 @@ function TerminalViewport({ terminalRef.current = terminal; fitAddonRef.current = fitAddon; - const api = readNativeApi(); - if (!api) return; - const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; if (selectionActionTimerRef.current !== null) { @@ -340,7 +344,7 @@ function TerminalViewport({ const requestId = ++selectionActionRequestIdRef.current; selectionActionOpenRef.current = true; try { - const clicked = await api.contextMenu.show( + const clicked = await localApi.contextMenu.show( [{ id: "add-to-chat", label: "Add to chat" }], nextAction.position, ); @@ -416,7 +420,7 @@ function TerminalViewport({ if (!latestTerminal) return; if (match.kind === "url") { - void api.shell.openExternal(match.text).catch((error) => { + void localApi.shell.openExternal(match.text).catch((error: unknown) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open link", @@ -426,7 +430,7 @@ function TerminalViewport({ } const target = resolvePathLinkTarget(match.text, cwd); - void openInPreferredEditor(api, target).catch((error) => { + void openInPreferredEditor(localApi, target).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", @@ -573,12 +577,12 @@ function TerminalViewport({ const previousLastEntryId = selectTerminalEventEntries( previousState.terminalEventEntriesByKey, - threadId, + threadRef, terminalId, ).at(-1)?.id ?? 0; const nextEntries = selectTerminalEventEntries( state.terminalEventEntriesByKey, - threadId, + threadRef, terminalId, ); const nextLastEntryId = nextEntries.at(-1)?.id ?? 0; @@ -608,7 +612,7 @@ function TerminalViewport({ writeTerminalSnapshot(activeTerminal, snapshot); const bufferedEntries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - threadId, + threadRef, terminalId, ); const replayEntries = selectTerminalEventEntriesAfterSnapshot( @@ -677,7 +681,7 @@ function TerminalViewport({ // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cwd, runtimeEnv, terminalId, threadId]); + }, [cwd, runtimeEnv, terminalId, threadId, threadRef]); useEffect(() => { if (!autoFocus) return; @@ -692,7 +696,7 @@ function TerminalViewport({ }, [autoFocus, focusRequestId]); useEffect(() => { - const api = readNativeApi(); + const api = readEnvironmentApi(threadRef.environmentId); const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; if (!api || !terminal || !fitAddon) return; @@ -714,13 +718,14 @@ function TerminalViewport({ return () => { window.cancelAnimationFrame(frame); }; - }, [drawerHeight, resizeEpoch, terminalId, threadId]); + }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]); return (
); } interface ThreadTerminalDrawerProps { + threadRef: ScopedThreadRef; threadId: ThreadId; cwd: string; worktreePath?: string | null; @@ -773,6 +778,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA } export default function ThreadTerminalDrawer({ + threadRef, threadId, cwd, worktreePath, @@ -1098,6 +1104,7 @@ export default function ThreadTerminalDrawer({ >
)} - {activeProjectName && } + {activeProjectName && ( + + )} ["draftsByThreadId"]; const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; - draftsByThreadId[threadId] = { - prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: { - [provider]: { - provider, - model, - ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), + useComposerDraftStore.setState({ + draftsByThreadKey: { + [threadKey]: { + prompt: props?.prompt ?? "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + modelSelectionByProvider: { + [provider]: { + provider, + model, + ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), + }, + }, + activeProvider: provider, + runtimeMode: null, + interactionMode: null, }, }, - activeProvider: provider, - runtimeMode: null, - interactionMode: null, - }; - useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, }); const host = document.createElement("div"); document.body.append(host); @@ -121,7 +129,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str { afterEach(() => { document.body.innerHTML = ""; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, }); }); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 2a1efb4b20..98aff58b0d 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,5 +1,5 @@ import { type ApprovalRequestId } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useRef } from "react"; +import { memo, useEffect, useEffectEvent, useRef } from "react"; import { type PendingUserInput } from "../../session-logic"; import { derivePendingUserInputProgress, @@ -60,6 +60,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; const autoAdvanceTimerRef = useRef(null); + const onAdvanceRef = useRef(onAdvance); + + useEffect(() => { + onAdvanceRef.current = onAdvance; + }, [onAdvance]); // Clear auto-advance timer on unmount useEffect(() => { @@ -70,22 +75,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; }, []); - const handleOptionSelection = useCallback( - (questionId: string, optionLabel: string) => { - onToggleOption(questionId, optionLabel); - if (activeQuestion?.multiSelect) { - return; - } - if (autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - } - autoAdvanceTimerRef.current = window.setTimeout(() => { - autoAdvanceTimerRef.current = null; - onAdvance(); - }, 200); - }, - [activeQuestion?.multiSelect, onAdvance, onToggleOption], - ); + const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => { + onToggleOption(questionId, optionLabel); + if (activeQuestion?.multiSelect) { + return; + } + if (autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + } + autoAdvanceTimerRef.current = window.setTimeout(() => { + autoAdvanceTimerRef.current = null; + onAdvanceRef.current(); + }, 200); + }); // Keyboard shortcut: number keys 1-9 select corresponding options when focus is // outside editable fields. Multi-select prompts toggle options in place; single- @@ -112,7 +114,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, handleOptionSelection, isResponding]); + }, [activeQuestion, isResponding]); if (!activeQuestion) { return null; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 40d34b36c1..c644867aac 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -42,6 +42,8 @@ beforeAll(() => { }); }); +const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); @@ -85,6 +87,7 @@ describe("MessagesTimeline", () => { onRevertUserMessage={() => {}} isRevertingCheckpoint={false} onImageExpand={() => {}} + activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} markdownCwd={undefined} resolvedTheme="light" timestampFormat="locale" @@ -130,6 +133,7 @@ describe("MessagesTimeline", () => { onRevertUserMessage={() => {}} isRevertingCheckpoint={false} onImageExpand={() => {}} + activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} markdownCwd={undefined} resolvedTheme="light" timestampFormat="locale" diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9be521b3be..5100824328 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,4 +1,4 @@ -import { type MessageId, type TurnId } from "@t3tools/contracts"; +import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts"; import { memo, useCallback, @@ -82,6 +82,7 @@ interface MessagesTimelineProps { onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; + activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; @@ -117,6 +118,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, isRevertingCheckpoint, onImageExpand, + activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, @@ -531,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx index 1e947a3c84..6a37c5b099 100644 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = { height: 1_100, }; const MARKDOWN_CWD = "/repo/project"; +const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; interface RowMeasurement { actualHeightPx: number; @@ -31,7 +32,10 @@ interface RowMeasurement { interface VirtualizationScenario { name: string; targetRowId: string; - props: Omit, "scrollContainer">; + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >; maxEstimateDeltaPx: number; } @@ -48,7 +52,10 @@ interface VirtualizerSnapshot { } function MessagesTimelineBrowserHarness( - props: Omit, "scrollContainer">, + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >, ) { const [scrollContainer, setScrollContainer] = useState(null); const [expandedWorkGroups, setExpandedWorkGroups] = useState>( @@ -73,6 +80,7 @@ function MessagesTimelineBrowserHarness( > ; onVirtualizerSnapshot?: ComponentProps["onVirtualizerSnapshot"]; -}): Omit, "scrollContainer"> { +}): Omit, "scrollContainer" | "activeThreadEnvironmentId"> { return { hasMessages: true, isWorking: false, @@ -481,7 +489,10 @@ async function waitForElement( async function measureTimelineRow(input: { host: HTMLElement; - props: Omit, "scrollContainer">; + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >; targetRowId: string; }): Promise { const scrollContainer = await waitForElement( @@ -550,7 +561,10 @@ async function measureTimelineRow(input: { } async function mountMessagesTimeline(input: { - props: Omit, "scrollContainer">; + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >; viewport?: { width: number; height: number }; }) { const viewport = input.viewport ?? DEFAULT_VIEWPORT; @@ -576,7 +590,10 @@ async function mountMessagesTimeline(input: { return { host, rerender: async ( - nextProps: Omit, "scrollContainer">, + nextProps: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >, ) => { await screen.rerender(); await waitForLayout(); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 703bfadaa3..b0f8aa3ea7 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -16,7 +16,7 @@ import { Zed, } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; -import { readNativeApi } from "~/nativeApi"; +import { readLocalApi } from "~/localApi"; const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ @@ -91,7 +91,7 @@ export const OpenInPicker = memo(function OpenInPicker({ const openInEditor = useCallback( (editorId: EditorId | null) => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api || !openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; @@ -108,7 +108,7 @@ export const OpenInPicker = memo(function OpenInPicker({ useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { - const api = readNativeApi(); + const api = readLocalApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!api || !openInCwd) return; if (!preferredEditor) return; diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index fc52c33225..a36cb097cb 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -1,4 +1,5 @@ import { memo, useState, useId } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { buildCollapsedProposedPlanPreviewMarkdown, buildProposedPlanMarkdownFilename, @@ -24,15 +25,17 @@ import { DialogTitle, } from "../ui/dialog"; import { toastManager } from "../ui/toast"; -import { readNativeApi } from "~/nativeApi"; +import { readEnvironmentApi } from "~/environmentApi"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, + environmentId, cwd, workspaceRoot, }: { planMarkdown: string; + environmentId: EnvironmentId; cwd: string | undefined; workspaceRoot: string | undefined; }) { @@ -82,7 +85,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ }; const handleSaveToWorkspace = () => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); const relativePath = savePath.trim(); if (!api || !workspaceRoot) { return; diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 74c22e6431..4dc7240c13 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -6,10 +6,11 @@ import { CodexModelOptions, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, - ProjectId, + EnvironmentId, type ServerProvider, ThreadId, } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { page } from "vitest/browser"; import { useCallback } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -27,7 +28,13 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; // ── Claude TraitsPicker tests ───────────────────────────────────────── +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); +const CLAUDE_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CLAUDE_THREAD_ID); +const CLAUDE_THREAD_KEY = scopedThreadKey(CLAUDE_THREAD_REF); +const CODEX_THREAD_ID = ThreadId.makeUnsafe("thread-codex-traits"); +const CODEX_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CODEX_THREAD_ID); +const CODEX_THREAD_KEY = scopedThreadKey(CODEX_THREAD_REF); const TEST_PROVIDERS: ReadonlyArray = [ { provider: "codex", @@ -120,10 +127,10 @@ function ClaudeTraitsPickerHarness(props: { fallbackModelSelection: ModelSelection | null; triggerVariant?: "ghost" | "outline"; }) { - const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt; + const prompt = useComposerThreadDraft(CLAUDE_THREAD_REF).prompt; const setPrompt = useComposerDraftStore((store) => store.setPrompt); const { modelOptions, selectedModel } = useEffectiveComposerModelState({ - threadId: CLAUDE_THREAD_ID, + threadRef: CLAUDE_THREAD_REF, providers: TEST_PROVIDERS, selectedProvider: "claudeAgent", threadModelSelection: props.fallbackModelSelection, @@ -135,7 +142,7 @@ function ClaudeTraitsPickerHarness(props: { }); const handlePromptChange = useCallback( (nextPrompt: string) => { - setPrompt(CLAUDE_THREAD_ID, nextPrompt); + setPrompt(CLAUDE_THREAD_REF, nextPrompt); }, [setPrompt], ); @@ -144,7 +151,7 @@ function ClaudeTraitsPickerHarness(props: { = { - [CLAUDE_THREAD_ID]: { + const draftsByThreadKey: Record = { + [CLAUDE_THREAD_KEY]: { prompt: props?.prompt ?? "", images: [], nonPersistedImageIds: [], @@ -192,9 +199,9 @@ async function mountClaudePicker(props?: { }, }; useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, }); const host = document.createElement("div"); document.body.append(host); @@ -230,9 +237,9 @@ describe("TraitsPicker (Claude)", () => { afterEach(() => { document.body.innerHTML = ""; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, }); }); @@ -369,10 +376,9 @@ describe("TraitsPicker (Claude)", () => { // ── Codex TraitsPicker tests ────────────────────────────────────────── async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) { - const threadId = ThreadId.makeUnsafe("thread-codex-traits"); const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex; - const draftsByThreadId: Record = { - [threadId]: { + const draftsByThreadKey: Record = { + [CODEX_THREAD_KEY]: { prompt: "", images: [], nonPersistedImageIds: [], @@ -392,10 +398,10 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt }; useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: { - [ProjectId.makeUnsafe("project-codex-traits")]: threadId, + draftsByThreadKey, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + "environment-local:project-codex-traits": CODEX_THREAD_KEY, }, }); const host = document.createElement("div"); @@ -404,7 +410,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt { document.body.innerHTML = ""; localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 061594ad53..14b5cdfb3c 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -3,8 +3,8 @@ import { type CodexModelOptions, type ProviderKind, type ProviderModelOptions, + type ScopedThreadRef, type ServerProviderModel, - type ThreadId, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -28,18 +28,19 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; -import { useComposerDraftStore } from "../../composerDraftStore"; +import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; type ProviderOptions = ProviderModelOptions[ProviderKind]; type TraitsPersistence = | { - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; onModelOptionsChange?: never; } | { - threadId?: undefined; + threadRef?: undefined; onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void; }; @@ -167,7 +168,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ persistence.onModelOptionsChange(nextOptions); return; } - setProviderModelOptions(persistence.threadId, provider, nextOptions, { persistSticky: true }); + const threadTarget = persistence.threadRef ?? persistence.draftId; + if (!threadTarget) { + return; + } + setProviderModelOptions(threadTarget, provider, nextOptions, { + persistSticky: true, + }); }, [persistence, provider, setProviderModelOptions], ); diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 4dc79832d4..1735117837 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { ServerProviderModel } from "@t3tools/contracts"; -import { getComposerProviderState } from "./composerProviderRegistry"; +import { + getComposerProviderState, + renderProviderTraitsMenuContent, + renderProviderTraitsPicker, +} from "./composerProviderRegistry"; const CODEX_MODELS: ReadonlyArray = [ { @@ -417,3 +421,31 @@ describe("getComposerProviderState", () => { expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); }); }); + +describe("provider traits render guards", () => { + it("returns null for codex traits picker when no thread target is provided", () => { + const content = renderProviderTraitsPicker({ + provider: "codex", + model: "gpt-5.4", + models: CODEX_MODELS, + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + }); + + expect(content).toBeNull(); + }); + + it("returns null for claude traits menu content when no thread target is provided", () => { + const content = renderProviderTraitsMenuContent({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + models: CLAUDE_MODELS, + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + }); + + expect(content).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 3307442db2..74d8d85cff 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -1,11 +1,12 @@ import { type ProviderKind, type ProviderModelOptions, + type ScopedThreadRef, type ServerProviderModel, - type ThreadId, } from "@t3tools/contracts"; import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; import type { ReactNode } from "react"; +import type { DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; import { @@ -33,7 +34,8 @@ export type ComposerProviderState = { type ProviderRegistryEntry = { getState: (input: ComposerProviderStateInput) => ComposerProviderState; renderTraitsMenuContent: (input: { - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -41,7 +43,8 @@ type ProviderRegistryEntry = { onPromptChange: (prompt: string) => void; }) => ReactNode; renderTraitsPicker: (input: { - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -50,6 +53,13 @@ type ProviderRegistryEntry = { }) => ReactNode; }; +function hasComposerTraitsTarget(input: { + threadRef: ScopedThreadRef | undefined; + draftId: DraftId | undefined; +}): boolean { + return input.threadRef !== undefined || input.draftId !== undefined; +} + function getProviderStateFromCapabilities( input: ComposerProviderStateInput, ): ComposerProviderState { @@ -94,66 +104,92 @@ const composerProviderRegistry: Record = { codex: { getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: ({ - threadId, + threadRef, + draftId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), + renderTraitsPicker: ({ + threadRef, + draftId, model, models, modelOptions, prompt, onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), }, claudeAgent: { getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: ({ - threadId, + threadRef, + draftId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), + renderTraitsPicker: ({ + threadRef, + draftId, model, models, modelOptions, prompt, onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), }, }; @@ -163,7 +199,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com export function renderProviderTraitsMenuContent(input: { provider: ProviderKind; - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -171,7 +208,8 @@ export function renderProviderTraitsMenuContent(input: { onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsMenuContent({ - threadId: input.threadId, + ...(input.threadRef ? { threadRef: input.threadRef } : {}), + ...(input.draftId ? { draftId: input.draftId } : {}), model: input.model, models: input.models, modelOptions: input.modelOptions, @@ -182,7 +220,8 @@ export function renderProviderTraitsMenuContent(input: { export function renderProviderTraitsPicker(input: { provider: ProviderKind; - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -190,7 +229,8 @@ export function renderProviderTraitsPicker(input: { onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsPicker({ - threadId: input.threadId, + ...(input.threadRef ? { threadRef: input.threadRef } : {}), + ...(input.draftId ? { draftId: input.draftId } : {}), model: input.model, models: input.models, modelOptions: input.modelOptions, diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index f0ea32d4be..ab2c8ab3f1 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1,17 +1,29 @@ import "../../index.css"; -import { DEFAULT_SERVER_SETTINGS, type NativeApi, type ServerConfig } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + type LocalApi, + type ServerConfig, +} from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { __resetNativeApiForTests } from "../../nativeApi"; +import { __resetLocalApiForTests } from "../../localApi"; import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; import { GeneralSettingsPanel } from "./SettingsPanels"; function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -32,14 +44,14 @@ function createBaseServerConfig(): ServerConfig { describe("GeneralSettingsPanel observability", () => { beforeEach(async () => { resetServerStateForTests(); - await __resetNativeApiForTests(); + await __resetLocalApiForTests(); localStorage.clear(); document.body.innerHTML = ""; }); afterEach(async () => { resetServerStateForTests(); - await __resetNativeApiForTests(); + await __resetLocalApiForTests(); document.body.innerHTML = ""; }); @@ -68,12 +80,12 @@ describe("GeneralSettingsPanel observability", () => { }); it("opens the logs folder in the preferred editor", async () => { - const openInEditor = vi.fn().mockResolvedValue(undefined); + const openInEditor = vi.fn().mockResolvedValue(undefined); window.nativeApi = { shell: { openInEditor, }, - } as unknown as NativeApi; + } as unknown as LocalApi; setServerConfigSnapshot(createBaseServerConfig()); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 97c84271a7..6251db7cd9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -13,11 +13,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PROVIDER_DISPLAY_NAMES, + type ScopedThreadRef, type ProviderKind, type ServerProvider, type ServerProviderModel, - ThreadId, } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; @@ -45,8 +46,12 @@ import { getCustomModelOptionsByProvider, resolveAppModelSelectionState, } from "../../modelSelection"; -import { ensureNativeApi, readNativeApi } from "../../nativeApi"; -import { useStore } from "../../store"; +import { ensureLocalApi, readLocalApi } from "../../localApi"; +import { + selectProjectsAcrossEnvironments, + selectThreadsAcrossEnvironments, + useStore, +} from "../../store"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; @@ -496,8 +501,8 @@ export function useSettingsRestore(onRestored?: () => void) { const restoreDefaults = useCallback(async () => { if (changedSettingLabels.length === 0) return; - const api = readNativeApi(); - const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( + const api = readLocalApi(); + const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( "\n", ), @@ -554,7 +559,7 @@ export function GeneralSettingsPanel() { if (refreshingRef.current) return; refreshingRef.current = true; setIsRefreshingProviders(true); - void ensureNativeApi() + void ensureLocalApi() .server.refreshProviders() .catch((error: unknown) => { console.warn("Failed to refresh providers", error); @@ -614,7 +619,7 @@ export function GeneralSettingsPanel() { return; } - void ensureNativeApi() + void ensureLocalApi() .shell.openInEditor(path, editor) .catch((error) => { setOpenPathErrorByTarget((existing) => ({ @@ -1479,20 +1484,10 @@ export function GeneralSettingsPanel() { } export function ArchivedThreadsPanel() { - const projectIds = useStore((store) => store.projectIds); - const projectById = useStore((store) => store.projectById); - const threadIds = useStore((store) => store.threadIds); - const threadShellById = useStore((store) => store.threadShellById); + const projects = useStore(selectProjectsAcrossEnvironments); + const threads = useStore(selectThreadsAcrossEnvironments); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); const archivedGroups = useMemo(() => { - const projects = projectIds.flatMap((projectId) => { - const project = projectById[projectId]; - return project ? [project] : []; - }); - const threads = threadIds.flatMap((threadId) => { - const thread = threadShellById[threadId]; - return thread ? [thread] : []; - }); return projects .map((project) => ({ project, @@ -1505,11 +1500,11 @@ export function ArchivedThreadsPanel() { }), })) .filter((group) => group.threads.length > 0); - }, [projectById, projectIds, threadIds, threadShellById]); + }, [projects, threads]); const handleArchivedThreadContextMenu = useCallback( - async (threadId: ThreadId, position: { x: number; y: number }) => { - const api = readNativeApi(); + async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + const api = readLocalApi(); if (!api) return; const clicked = await api.contextMenu.show( [ @@ -1521,7 +1516,7 @@ export function ArchivedThreadsPanel() { if (clicked === "unarchive") { try { - await unarchiveThread(threadId); + await unarchiveThread(threadRef); } catch (error) { toastManager.add({ type: "error", @@ -1533,7 +1528,7 @@ export function ArchivedThreadsPanel() { } if (clicked === "delete") { - await confirmAndDeleteThread(threadId); + await confirmAndDeleteThread(threadRef); } }, [confirmAndDeleteThread, unarchiveThread], @@ -1566,10 +1561,13 @@ export function ArchivedThreadsPanel() { className="flex items-center justify-between gap-3 border-t border-border px-4 py-3 first:border-t-0 sm:px-5" onContextMenu={(event) => { event.preventDefault(); - void handleArchivedThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); + void handleArchivedThreadContextMenu( + scopeThreadRef(thread.environmentId, thread.id), + { + x: event.clientX, + y: event.clientY, + }, + ); }} >
@@ -1586,13 +1584,16 @@ export function ArchivedThreadsPanel() { size="sm" className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" onClick={() => - void unarchiveThread(thread.id).catch((error) => { - toastManager.add({ - type: "error", - title: "Failed to unarchive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }) + void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch( + (error) => { + toastManager.add({ + type: "error", + title: "Failed to unarchive thread", + description: + error instanceof Error ? error.message : "An error occurred.", + }); + }, + ) } > diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 797e27a6ed..8432680d42 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,5 +1,12 @@ +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import * as Schema from "effect/Schema"; import { + EnvironmentId, ProjectId, ThreadId, type ModelSelection, @@ -9,10 +16,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { COMPOSER_DRAFT_STORAGE_KEY, - clearPromotedDraftThread, - clearPromotedDraftThreads, + finalizePromotedDraftThreadByRef, + markPromotedDraftThread, + markPromotedDraftThreadByRef, + markPromotedDraftThreads, + markPromotedDraftThreadsByRef, type ComposerImageAttachment, useComposerDraftStore, + DraftId, } from "./composerDraftStore"; import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; import { @@ -71,9 +82,9 @@ function makeTerminalContext(input: { function resetComposerDraftStore() { useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); @@ -95,8 +106,32 @@ function providerModelOptions(options: ProviderModelOptions): ProviderModelOptio return options; } +const TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); +const OTHER_TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-remote"); +const LEGACY_TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("__legacy__"); + +function threadKeyFor( + threadId: ThreadId, + environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID, +): string { + if (environmentId === LEGACY_TEST_ENVIRONMENT_ID) { + return threadId; + } + return scopedThreadKey(scopeThreadRef(environmentId, threadId)); +} + +function draftFor(threadId: ThreadId, environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID) { + const store = useComposerDraftStore.getState().draftsByThreadKey; + return store[threadKeyFor(threadId, environmentId)] ?? store[threadId] ?? undefined; +} + +function draftByKey(key: string) { + return useComposerDraftStore.getState().draftsByThreadKey[key] ?? undefined; +} + describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType void>>; @@ -129,9 +164,9 @@ describe("composerDraftStore addImages", () => { lastModified: 12345, }); - useComposerDraftStore.getState().addImages(threadId, [first, duplicate]); + useComposerDraftStore.getState().addImages(threadRef, [first, duplicate]); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]); expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); }); @@ -154,10 +189,10 @@ describe("composerDraftStore addImages", () => { lastModified: 999, }); - useComposerDraftStore.getState().addImage(threadId, first); - useComposerDraftStore.getState().addImage(threadId, duplicateLater); + useComposerDraftStore.getState().addImage(threadRef, first); + useComposerDraftStore.getState().addImage(threadRef, duplicateLater); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]); expect(revokeSpy).toHaveBeenCalledWith("blob:b"); }); @@ -172,9 +207,9 @@ describe("composerDraftStore addImages", () => { previewUrl: "blob:shared", }); - useComposerDraftStore.getState().addImages(threadId, [first, duplicateSameUrl]); + useComposerDraftStore.getState().addImages(threadRef, [first, duplicateSameUrl]); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]); expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); }); @@ -182,6 +217,7 @@ describe("composerDraftStore addImages", () => { describe("composerDraftStore clearComposerContent", () => { const threadId = ThreadId.makeUnsafe("thread-clear"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType void>>; @@ -201,11 +237,11 @@ describe("composerDraftStore clearComposerContent", () => { id: "img-optimistic", previewUrl: "blob:optimistic", }); - useComposerDraftStore.getState().addImage(threadId, first); + useComposerDraftStore.getState().addImage(threadRef, first); - useComposerDraftStore.getState().clearComposerContent(threadId); + useComposerDraftStore.getState().clearComposerContent(threadRef); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft).toBeUndefined(); expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); }); @@ -213,13 +249,14 @@ describe("composerDraftStore clearComposerContent", () => { describe("composerDraftStore syncPersistedAttachments", () => { const threadId = ThreadId.makeUnsafe("thread-sync-persisted"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); @@ -234,7 +271,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { id: "img-persisted", previewUrl: "blob:persisted", }); - useComposerDraftStore.getState().addImage(threadId, image); + useComposerDraftStore.getState().addImage(threadRef, image); setLocalStorageItem( COMPOSER_DRAFT_STORAGE_KEY, { @@ -250,7 +287,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { Schema.Unknown, ); - useComposerDraftStore.getState().syncPersistedAttachments(threadId, [ + useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [ { id: image.id, name: image.name, @@ -261,23 +298,20 @@ describe("composerDraftStore syncPersistedAttachments", () => { ]); await Promise.resolve(); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments, - ).toEqual([]); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedImageIds, - ).toEqual([image.id]); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual([]); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.nonPersistedImageIds).toEqual([image.id]); }); }); describe("composerDraftStore terminal contexts", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); @@ -287,20 +321,20 @@ describe("composerDraftStore terminal contexts", () => { const first = makeTerminalContext({ id: "ctx-1" }); const duplicate = makeTerminalContext({ id: "ctx-2" }); - useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]); + useComposerDraftStore.getState().addTerminalContexts(threadRef, [first, duplicate]); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]); }); it("clears terminal contexts when clearing composer content", () => { useComposerDraftStore .getState() - .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-1" })); + .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-1" })); - useComposerDraftStore.getState().clearComposerContent(threadId); + useComposerDraftStore.getState().clearComposerContent(threadRef); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); }); it("inserts terminal contexts at the requested inline prompt position", () => { @@ -311,7 +345,7 @@ describe("composerDraftStore terminal contexts", () => { useComposerDraftStore .getState() .insertTerminalContext( - threadId, + threadRef, firstInsertion.prompt, makeTerminalContext({ id: "ctx-1" }), firstInsertion.contextIndex, @@ -319,7 +353,7 @@ describe("composerDraftStore terminal contexts", () => { ).toBe(true); expect( useComposerDraftStore.getState().insertTerminalContext( - threadId, + threadRef, secondInsertion.prompt, makeTerminalContext({ id: "ctx-2", @@ -331,7 +365,7 @@ describe("composerDraftStore terminal contexts", () => { ), ).toBe(true); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.prompt).toBe( `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`, ); @@ -341,7 +375,7 @@ describe("composerDraftStore terminal contexts", () => { it("omits terminal context text from persisted drafts", () => { useComposerDraftStore .getState() - .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-persist" })); + .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" })); const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { @@ -349,11 +383,12 @@ describe("composerDraftStore terminal contexts", () => { }; }; const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { - draftsByThreadId?: Record> }>; + draftsByThreadKey?: Record> }>; }; expect( - persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0], + persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] + ?.terminalContexts?.[0], "Expected terminal context metadata to be persisted.", ).toMatchObject({ id: "ctx-persist", @@ -363,7 +398,8 @@ describe("composerDraftStore terminal contexts", () => { lineEnd: 5, }); expect( - persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0]?.text, + persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] + ?.terminalContexts?.[0]?.text, ).toBeUndefined(); }); @@ -396,12 +432,12 @@ describe("composerDraftStore terminal contexts", () => { }, }, draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + projectDraftThreadIdByProjectKey: {}, }, useComposerDraftStore.getInitialState(), ); - expect(mergedState.draftsByThreadId[threadId]?.terminalContexts).toMatchObject([ + expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.terminalContexts).toMatchObject([ { id: "ctx-rehydrated", terminalId: "default", @@ -434,22 +470,30 @@ describe("composerDraftStore terminal contexts", () => { }, }, draftThreadsByThreadId: "not-an-object", - projectDraftThreadIdByProjectId: "not-an-object", + projectDraftThreadIdByProjectKey: "not-an-object", }, useComposerDraftStore.getInitialState(), ); - expect(mergedState.draftsByThreadId[threadId]).toBeUndefined(); - expect(mergedState.draftThreadsByThreadId).toEqual({}); - expect(mergedState.projectDraftThreadIdByProjectId).toEqual({}); + expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined(); + expect(mergedState.draftThreadsByThreadKey).toEqual({}); + expect(mergedState.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual({}); }); }); describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.makeUnsafe("project-a"); const otherProjectId = ProjectId.makeUnsafe("project-b"); + const projectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, projectId); + const otherProjectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, otherProjectId); + const remoteProjectRef = scopeProjectRef(OTHER_TEST_ENVIRONMENT_ID, projectId); const threadId = ThreadId.makeUnsafe("thread-a"); const otherThreadId = ThreadId.makeUnsafe("thread-b"); + const draftId = DraftId.makeUnsafe("draft-a"); + const otherDraftId = DraftId.makeUnsafe("draft-b"); + const sharedDraftId = DraftId.makeUnsafe("draft-shared"); + const localDraftId = DraftId.makeUnsafe("draft-local"); + const remoteDraftId = DraftId.makeUnsafe("draft-remote"); beforeEach(() => { resetComposerDraftStore(); @@ -457,17 +501,20 @@ describe("composerDraftStore project draft thread mapping", () => { it("stores and reads project draft thread ids via actions", () => { const store = useComposerDraftStore.getState(); - expect(store.getDraftThreadByProjectId(projectId)).toBeNull(); - expect(store.getDraftThread(threadId)).toBeNull(); + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(store.getDraftThread(draftId)).toBeNull(); - store.setProjectDraftThreadId(projectId, threadId, { + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, branch: "feature/test", worktreePath: "/tmp/worktree-test", createdAt: "2026-01-01T00:00:00.000Z", }); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({ + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toMatchObject({ threadId, + environmentId: TEST_ENVIRONMENT_ID, projectId, + logicalProjectKey: scopedProjectKey(projectRef), branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", @@ -475,8 +522,10 @@ describe("composerDraftStore project draft thread mapping", () => { interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", }); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({ + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, projectId, + logicalProjectKey: scopedProjectKey(projectRef), branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", @@ -488,134 +537,210 @@ describe("composerDraftStore project draft thread mapping", () => { it("clears only matching project draft mapping entries", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "hello"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "hello"); - store.clearProjectDraftThreadById(projectId, otherThreadId); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( + store.clearProjectDraftThreadById(projectRef, otherDraftId); + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( threadId, ); - store.clearProjectDraftThreadById(projectId, threadId); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + store.clearProjectDraftThreadById(projectRef, draftId); + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); }); it("clears project draft mapping by project id", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "hello"); - store.clearProjectDraftThreadId(projectId); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "hello"); + store.clearProjectDraftThreadId(projectRef); + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); }); it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "orphan me"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "orphan me"); - store.setProjectDraftThreadId(projectId, otherThreadId); + store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( otherThreadId, ); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); }); it("keeps composer drafts when the thread is still mapped by another project", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setProjectDraftThreadId(otherProjectId, threadId); - store.setPrompt(threadId, "keep me"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setProjectDraftThreadId(otherProjectRef, sharedDraftId, { threadId }); + store.setPrompt(sharedDraftId, "keep me"); - store.clearProjectDraftThreadId(projectId); + store.clearProjectDraftThreadId(projectRef); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); expect( - useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, + useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId, ).toBe(threadId); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); + expect(draftByKey(sharedDraftId)?.prompt).toBe("keep me"); }); it("clears draft registration independently", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "remove me"); - store.clearDraftThread(threadId); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "remove me"); + store.clearDraftThread(draftId); + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); + }); + + it("marks a promoted draft by thread id without deleting composer state", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + + markPromotedDraftThread(threadId); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( + scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), + ); + expect(draftByKey(draftId)?.prompt).toBe("promote me"); }); - it("clears a promoted draft by thread id", () => { + it("reads local draft composer state through a scoped thread ref", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "promote me"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); - clearPromotedDraftThread(threadId); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "scoped access"); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(store.getComposerDraft(draftId)?.prompt).toBe("scoped access"); + expect(store.getComposerDraft(threadRef)?.prompt).toBe("scoped access"); }); it("does not clear composer drafts for existing server threads during promotion cleanup", () => { const store = useComposerDraftStore.getState(); - store.setPrompt(threadId, "keep me"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + store.setPrompt(threadRef, "keep me"); - clearPromotedDraftThread(threadId); + markPromotedDraftThread(threadId); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); + expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); }); - it("clears promoted drafts from an iterable of server thread ids", () => { + it("marks promoted drafts from an iterable of server thread ids", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "promote me"); - store.setProjectDraftThreadId(otherProjectId, otherThreadId); - store.setPrompt(otherThreadId, "keep me"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId }); + store.setPrompt(otherDraftId, "keep me"); - clearPromotedDraftThreads([threadId]); + markPromotedDraftThreads([threadId]); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( + scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), + ); + expect(draftByKey(draftId)?.prompt).toBe("promote me"); expect( - useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, + useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId, ).toBe(otherThreadId); - expect(useComposerDraftStore.getState().draftsByThreadId[otherThreadId]?.prompt).toBe( - "keep me", + expect(draftByKey(otherDraftId)?.prompt).toBe("keep me"); + }); + + it("marks every matching scoped draft when multiple environments share a thread id", () => { + const store = useComposerDraftStore.getState(); + const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId); + + store.setProjectDraftThreadId(projectRef, localDraftId, { threadId }); + store.setPrompt(localDraftId, "local draft"); + store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { threadId }); + store.setPrompt(remoteDraftId, "remote draft"); + + markPromotedDraftThread(threadId); + + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(store.getDraftThreadByProjectRef(remoteProjectRef)).toBeNull(); + expect(store.getDraftThreadByRef(localThreadRef)?.promotedTo).toEqual(localThreadRef); + expect(store.getDraftThreadByRef(remoteThreadRef)?.promotedTo).toEqual(remoteThreadRef); + expect(draftByKey(localDraftId)?.prompt).toBe("local draft"); + expect(draftByKey(remoteDraftId)?.prompt).toBe("remote draft"); + }); + + it("only marks promoted drafts for the matching environment ref", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + + markPromotedDraftThreadByRef(scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId)); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( + threadId, + ); + expect(draftByKey(draftId)?.prompt).toBe("promote me"); + }); + + it("only marks iterable promotion cleanup entries for the matching environment refs", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + + markPromotedDraftThreadsByRef([scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId)]); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( + threadId, ); + expect(draftByKey(draftId)?.prompt).toBe("promote me"); }); it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => { const store = useComposerDraftStore.getState(); - store.setPrompt(threadId, "keep me"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + store.setPrompt(threadRef, "keep me"); - clearPromotedDraftThreads([threadId]); + markPromotedDraftThreads([threadId]); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); + expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); + }); + + it("finalizes a promoted draft after the canonical thread route is active", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + markPromotedDraftThread(threadId); + + finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); }); it("updates branch context on an existing draft thread", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId, { + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, branch: "main", worktreePath: null, }); - store.setDraftThreadContext(threadId, { + store.setDraftThreadContext(draftId, { branch: "feature/next", worktreePath: "/tmp/feature-next", }); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( threadId, ); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, projectId, branch: "feature/next", worktreePath: "/tmp/feature-next", @@ -625,7 +750,8 @@ describe("composerDraftStore project draft thread mapping", () => { it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId, { + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, branch: "main", worktreePath: "/tmp/main-worktree", }); @@ -636,9 +762,10 @@ describe("composerDraftStore project draft thread mapping", () => { branch?: string | null; worktreePath?: string | null; }; - store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions); + store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, projectId, branch: "main", worktreePath: "/tmp/main-worktree", @@ -648,7 +775,8 @@ describe("composerDraftStore project draft thread mapping", () => { it("preserves worktree env mode without a worktree path", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId, { + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, branch: "feature/base", worktreePath: null, envMode: "worktree", @@ -662,9 +790,10 @@ describe("composerDraftStore project draft thread mapping", () => { worktreePath?: string | null; envMode?: "local" | "worktree"; }; - store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions); + store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, projectId, branch: "feature/base", worktreePath: null, @@ -675,6 +804,7 @@ describe("composerDraftStore project draft thread mapping", () => { describe("composerDraftStore modelSelection", () => { const threadId = ThreadId.makeUnsafe("thread-model-options"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); @@ -683,16 +813,14 @@ describe("composerDraftStore modelSelection", () => { it("stores a model selection in the draft", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( - threadId, + threadRef, modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "xhigh", fastMode: true, }), ); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "xhigh", fastMode: true, @@ -702,18 +830,18 @@ describe("composerDraftStore modelSelection", () => { it("keeps default-only model selections on the draft", () => { const store = useComposerDraftStore.getState(); - store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4")); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, - ).toEqual(modelSelection("codex", "gpt-5.4")); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( + modelSelection("codex", "gpt-5.4"), + ); }); it("replaces only the targeted provider options on the current model selection", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( - threadId, + threadRef, modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max", fastMode: true, @@ -727,7 +855,7 @@ describe("composerDraftStore modelSelection", () => { ); store.setProviderModelOptions( - threadId, + threadRef, "claudeAgent", { thinking: false, @@ -735,10 +863,7 @@ describe("composerDraftStore modelSelection", () => { { persistSticky: true }, ); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider - .claudeAgent, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, }), @@ -754,20 +879,17 @@ describe("composerDraftStore modelSelection", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( - threadId, + threadRef, modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max", }), ); - store.setProviderModelOptions(threadId, "claudeAgent", { + store.setProviderModelOptions(threadRef, "claudeAgent", { thinking: true, }); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider - .claudeAgent, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: true, }), @@ -778,16 +900,14 @@ describe("composerDraftStore modelSelection", () => { it("keeps explicit off/default codex overrides on the selection", () => { const store = useComposerDraftStore.getState(); - store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true })); + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true })); - store.setProviderModelOptions(threadId, "codex", { + store.setProviderModelOptions(threadRef, "codex", { reasoningEffort: "high", fastMode: false, }); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.4", { reasoningEffort: "high", fastMode: false, @@ -802,18 +922,15 @@ describe("composerDraftStore modelSelection", () => { modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); store.setModelSelection( - threadId, + threadRef, modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); - store.setProviderModelOptions(threadId, "claudeAgent", { + store.setProviderModelOptions(threadRef, "claudeAgent", { thinking: false, }); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider - .claudeAgent, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, }), @@ -828,7 +945,7 @@ describe("composerDraftStore modelSelection", () => { // Set options for both providers store.setModelOptions( - threadId, + threadRef, providerModelOptions({ codex: { fastMode: true }, claudeAgent: { effort: "max" }, @@ -836,9 +953,9 @@ describe("composerDraftStore modelSelection", () => { ); // Now set options for only codex — claudeAgent should be untouched - store.setModelOptions(threadId, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); + store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); }); @@ -847,16 +964,16 @@ describe("composerDraftStore modelSelection", () => { const store = useComposerDraftStore.getState(); store.setModelOptions( - threadId, + threadRef, providerModelOptions({ codex: { fastMode: true }, claudeAgent: { effort: "max" }, }), ); - store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6")); + store.setModelSelection(threadRef, modelSelection("claudeAgent", "claude-opus-4-6")); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); @@ -867,10 +984,10 @@ describe("composerDraftStore modelSelection", () => { it("creates the first sticky snapshot from provider option changes", () => { const store = useComposerDraftStore.getState(); - store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4")); store.setProviderModelOptions( - threadId, + threadRef, "codex", { fastMode: true, @@ -892,12 +1009,12 @@ describe("composerDraftStore modelSelection", () => { modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); store.setModelSelection( - threadId, + threadRef, modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); store.setProviderModelOptions( - threadId, + threadRef, "claudeAgent", { thinking: false, @@ -905,10 +1022,7 @@ describe("composerDraftStore modelSelection", () => { { persistSticky: false }, ); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider - .claudeAgent, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, }), @@ -921,6 +1035,7 @@ describe("composerDraftStore modelSelection", () => { describe("composerDraftStore setModelSelection", () => { const threadId = ThreadId.makeUnsafe("thread-model"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); @@ -929,11 +1044,11 @@ describe("composerDraftStore setModelSelection", () => { it("keeps explicit model overrides instead of coercing to null", () => { const store = useComposerDraftStore.getState(); - store.setModelSelection(threadId, modelSelection("codex", "gpt-5.3-codex")); + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.3-codex")); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, - ).toEqual(modelSelection("codex", "gpt-5.3-codex")); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( + modelSelection("codex", "gpt-5.3-codex"), + ); }); }); @@ -975,11 +1090,12 @@ describe("composerDraftStore sticky composer settings", () => { it("applies sticky activeProvider to new drafts", () => { const store = useComposerDraftStore.getState(); const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6")); - store.applyStickyState(threadId); + store.applyStickyState(threadRef); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({ modelSelectionByProvider: { claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"), }, @@ -990,6 +1106,7 @@ describe("composerDraftStore sticky composer settings", () => { describe("composerDraftStore provider-scoped option updates", () => { const threadId = ThreadId.makeUnsafe("thread-provider"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); @@ -998,13 +1115,13 @@ describe("composerDraftStore provider-scoped option updates", () => { it("retains off-provider option memory without changing the active selection", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( - threadId, + threadRef, modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium", }), ); - store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" }); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + store.setProviderModelOptions(threadRef, "claudeAgent", { effort: "max" }); + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), ); @@ -1015,6 +1132,7 @@ describe("composerDraftStore provider-scoped option updates", () => { describe("composerDraftStore runtime and interaction settings", () => { const threadId = ThreadId.makeUnsafe("thread-settings"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); @@ -1023,32 +1141,28 @@ describe("composerDraftStore runtime and interaction settings", () => { it("stores runtime mode overrides in the composer draft", () => { const store = useComposerDraftStore.getState(); - store.setRuntimeMode(threadId, "approval-required"); + store.setRuntimeMode(threadRef, "approval-required"); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.runtimeMode).toBe( - "approval-required", - ); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.runtimeMode).toBe("approval-required"); }); it("stores interaction mode overrides in the composer draft", () => { const store = useComposerDraftStore.getState(); - store.setInteractionMode(threadId, "plan"); + store.setInteractionMode(threadRef, "plan"); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.interactionMode).toBe( - "plan", - ); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.interactionMode).toBe("plan"); }); it("removes empty settings-only drafts when overrides are cleared", () => { const store = useComposerDraftStore.getState(); - store.setRuntimeMode(threadId, "approval-required"); - store.setInteractionMode(threadId, "plan"); - store.setRuntimeMode(threadId, null); - store.setInteractionMode(threadId, null); + store.setRuntimeMode(threadRef, "approval-required"); + store.setInteractionMode(threadRef, "plan"); + store.setRuntimeMode(threadRef, null); + store.setInteractionMode(threadRef, null); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8a93b7b0da..0df2332e0c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -3,6 +3,7 @@ import { type ClaudeCodeEffort, type CodexReasoningEffort, DEFAULT_MODEL_BY_PROVIDER, + type EnvironmentId, ModelSelection, ProjectId, ProviderInteractionMode, @@ -10,8 +11,18 @@ import { ProviderModelOptions, RuntimeMode, type ServerProvider, + type ScopedProjectRef, + type ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; +import { + parseScopedProjectKey, + parseScopedThreadKey, + scopedProjectKey, + scopeProjectRef, + scopedThreadKey, + scopeThreadRef, +} from "@t3tools/client-runtime"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; @@ -32,10 +43,13 @@ import { getDefaultServerModel } from "./providerModels"; import { UnifiedSettings } from "@t3tools/contracts/settings"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 3; +const COMPOSER_DRAFT_STORAGE_VERSION = 5; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; +export const DraftId = Schema.String.pipe(Schema.brand("DraftId")); +export type DraftId = typeof DraftId.Type; + const COMPOSER_PERSIST_DEBOUNCE_MS = 300; const composerDebouncedStorage = createDebouncedStorage( @@ -44,7 +58,7 @@ const composerDebouncedStorage = createDebouncedStorage( ); // Flush pending composer draft writes before page unload to prevent data loss. -if (typeof window !== "undefined") { +if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { composerDebouncedStorage.flush(); }); @@ -122,6 +136,14 @@ type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; type LegacyV2StoreFields = { stickyModelSelection?: ModelSelection | null; stickyModelOptions?: ProviderModelOptions | null; + projectDraftThreadIdByProjectId?: Record | null; + draftsByThreadId?: Record | null; + draftThreadsByThreadId?: Record | null; + projectDraftThreadIdByProjectKey?: Record | null; + draftsByThreadKey?: Record | null; + draftThreadsByThreadKey?: Record | null; + projectDraftThreadKeyByProjectKey?: Record | null; + logicalProjectDraftThreadKeyByLogicalProjectKey?: Record | null; }; type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & @@ -129,20 +151,31 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & LegacyV2StoreFields; const PersistedDraftThreadState = Schema.Struct({ + threadId: ThreadId, + environmentId: Schema.String, projectId: ProjectId, + logicalProjectKey: Schema.optionalKey(Schema.String), createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, + promotedTo: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + environmentId: Schema.String, + threadId: Schema.String, + }), + ), + ), }); type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; const PersistedComposerDraftStoreState = Schema.Struct({ - draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), - draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), - projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), + draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState), + draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState), + logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String), stickyModelSelectionByProvider: Schema.optionalKey( Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), ), @@ -155,6 +188,10 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ state: PersistedComposerDraftStoreState, }); +/** + * Composer content keyed by either a draft session (`DraftId`) or a real server + * thread (`ScopedThreadRef`). This is the editable payload shown in the composer. + */ export interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; @@ -167,32 +204,94 @@ export interface ComposerThreadDraftState { interactionMode: ProviderInteractionMode | null; } -export interface DraftThreadState { +/** + * Mutable routing and execution context for a pre-thread draft session. + * + * Unlike a real server thread, a draft session can still change target + * environment/worktree configuration before the first send. + */ +export interface DraftSessionState { + threadId: ThreadId; + environmentId: EnvironmentId; projectId: ProjectId; + logicalProjectKey: string; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; + promotedTo?: ScopedThreadRef | null; } -interface ProjectDraftThread extends DraftThreadState { - threadId: ThreadId; +export type DraftThreadState = DraftSessionState; + +/** + * Draft session metadata paired with its stable draft-session identity. + */ +interface ProjectDraftSession extends DraftSessionState { + draftId: DraftId; } +/** + * App-facing composer identity: + * - `DraftId` for pre-thread draft sessions + * - `ScopedThreadRef` for server-backed threads + * + * Raw `ThreadId` is intentionally excluded so callers cannot drop environment + * identity for real threads. + */ +type ComposerThreadTarget = ScopedThreadRef | DraftId; + +/** + * Persisted store for composer content plus draft-session metadata. + * + * The store intentionally models two domains: + * - draft sessions keyed by `DraftId` + * - server thread composer state keyed by `ScopedThreadRef` + */ interface ComposerDraftStoreState { - draftsByThreadId: Record; - draftThreadsByThreadId: Record; - projectDraftThreadIdByProjectId: Record; + draftsByThreadKey: Record; + draftThreadsByThreadKey: Record; + logicalProjectDraftThreadKeyByLogicalProjectKey: Record; stickyModelSelectionByProvider: Partial>; stickyActiveProvider: ProviderKind | null; - getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; - getDraftThread: (threadId: ThreadId) => DraftThreadState | null; + /** Returns the editable composer content for a draft session or server thread. */ + getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null; + /** Looks up the active draft session for a logical project identity. */ + getDraftThreadByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null; + getDraftSessionByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null; + getDraftThreadByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; + getDraftSessionByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; + /** Reads mutable draft-session metadata by `DraftId`. */ + getDraftSession: (draftId: DraftId) => DraftSessionState | null; + /** Resolves a server-thread ref back to a matching draft session when one exists. */ + getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null; + getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null; + getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; + listDraftThreadKeys: () => string[]; + hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean; + /** Creates or updates the draft session tracked for a logical project. */ + setLogicalProjectDraftThreadId: ( + logicalProjectKey: string, + projectRef: ScopedProjectRef, + draftId: DraftId, + options?: { + threadId?: ThreadId; + branch?: string | null; + worktreePath?: string | null; + createdAt?: string; + envMode?: DraftThreadEnvMode; + runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; + }, + ) => void; + /** Creates or updates the draft session tracked for a concrete project ref. */ setProjectDraftThreadId: ( - projectId: ProjectId, - threadId: ThreadId, + projectRef: ScopedProjectRef, + draftId: DraftId, options?: { + threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; createdAt?: string; @@ -201,65 +300,76 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; }, ) => void; + /** Updates mutable draft-session metadata without touching composer content. */ setDraftThreadContext: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, options: { branch?: string | null; worktreePath?: string | null; - projectId?: ProjectId; + projectRef?: ScopedProjectRef; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; - clearProjectDraftThreadId: (projectId: ProjectId) => void; - clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; - clearDraftThread: (threadId: ThreadId) => void; + clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; + clearProjectDraftThreadById: ( + projectRef: ScopedProjectRef, + threadRef: ComposerThreadTarget, + ) => void; + /** Marks a draft session as being promoted to a real server thread. */ + markDraftThreadPromoting: (threadRef: ComposerThreadTarget, promotedTo?: ScopedThreadRef) => void; + /** Removes draft-session metadata after promotion is complete. */ + finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void; + clearDraftThread: (threadRef: ComposerThreadTarget) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; - setPrompt: (threadId: ThreadId, prompt: string) => void; - setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; + setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; + setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; setModelSelection: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, ) => void; setModelOptions: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, modelOptions: ProviderModelOptions | null | undefined, ) => void; - applyStickyState: (threadId: ThreadId) => void; + applyStickyState: (threadRef: ComposerThreadTarget) => void; setProviderModelOptions: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, provider: ProviderKind, nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, options?: { persistSticky?: boolean; }, ) => void; - setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; + setRuntimeMode: ( + threadRef: ComposerThreadTarget, + runtimeMode: RuntimeMode | null | undefined, + ) => void; setInteractionMode: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, interactionMode: ProviderInteractionMode | null | undefined, ) => void; - addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; - addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; - removeImage: (threadId: ThreadId, imageId: string) => void; + addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void; + addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void; + removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void; insertTerminalContext: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, prompt: string, context: TerminalContextDraft, index: number, ) => boolean; - addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void; - addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; - removeTerminalContext: (threadId: ThreadId, contextId: string) => void; - clearTerminalContexts: (threadId: ThreadId) => void; - clearPersistedAttachments: (threadId: ThreadId) => void; + addTerminalContext: (threadRef: ComposerThreadTarget, context: TerminalContextDraft) => void; + addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; + removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void; + clearTerminalContexts: (threadRef: ComposerThreadTarget) => void; + clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, attachments: PersistedComposerImageAttachment[], ) => void; - clearComposerContent: (threadId: ThreadId) => void; + clearComposerContent: (threadRef: ComposerThreadTarget) => void; } export interface EffectiveComposerModelState { @@ -293,9 +403,9 @@ function modelSelectionByProviderToOptions( } const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); @@ -748,35 +858,323 @@ function normalizeDraftThreadEnvMode( return fallbackWorktreePath ? "worktree" : "local"; } +function projectDraftKey(projectRef: ScopedProjectRef): string { + return scopedProjectKey(projectRef); +} + +function logicalProjectDraftKey(logicalProjectKey: string): string { + return logicalProjectKey.trim(); +} + +/** + * Runtime composer storage key for app-facing identities only. + * + * Draft sessions are keyed by `DraftId`. Real threads are keyed by + * `ScopedThreadRef` so environment identity is always preserved. + */ +function composerTargetKey(target: ScopedThreadRef | DraftId): string { + if (typeof target === "string") { + return target.trim(); + } + return scopedThreadKey(target); +} + +/** + * Legacy persisted data may still be keyed by a raw `ThreadId`. This helper is + * intentionally migration-only so live code cannot accidentally accept that + * incomplete identity. + */ +function normalizeLegacyComposerStorageKey( + threadKeyOrId: string, + options?: { + environmentId?: EnvironmentId; + }, +): string { + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); + if (parsedThreadRef) { + return composerTargetKey(parsedThreadRef); + } + if (options?.environmentId) { + return composerTargetKey(scopeThreadRef(options.environmentId, threadKeyOrId as ThreadId)); + } + return threadKeyOrId; +} + +function composerThreadRefFromKey(threadKey: string): ScopedThreadRef | null { + return parseScopedThreadKey(threadKey); +} + +type ComposerThreadLookupState = Pick< + ComposerDraftStoreState, + "draftsByThreadKey" | "draftThreadsByThreadKey" +>; + +function normalizeComposerTarget( + state: ComposerThreadLookupState, + target: ComposerThreadTarget, +): ComposerThreadTarget | null { + if (typeof target === "string") { + const draftId = target.trim(); + return draftId.length > 0 ? DraftId.makeUnsafe(draftId) : null; + } + return target; +} + +function resolveComposerDraftKey( + state: ComposerThreadLookupState, + target: ComposerThreadTarget, +): string | null { + const normalizedTarget = normalizeComposerTarget(state, target); + if (!normalizedTarget) { + return null; + } + if (typeof normalizedTarget !== "string") { + const scopedKey = composerTargetKey(normalizedTarget); + if (state.draftsByThreadKey[scopedKey]) { + return scopedKey; + } + for (const [draftId, draftSession] of Object.entries(state.draftThreadsByThreadKey)) { + if ( + draftSession.environmentId === normalizedTarget.environmentId && + draftSession.threadId === normalizedTarget.threadId + ) { + return draftId; + } + } + return scopedKey; + } + const threadKey = composerTargetKey(normalizedTarget); + return threadKey.length > 0 ? threadKey : null; +} + +function resolveComposerThreadId( + state: ComposerThreadLookupState, + target: ComposerThreadTarget, +): ThreadId | null { + const normalizedTarget = normalizeComposerTarget(state, target); + if (!normalizedTarget) { + return null; + } + if (typeof normalizedTarget !== "string") { + return normalizedTarget.threadId; + } + return state.draftThreadsByThreadKey[normalizedTarget]?.threadId ?? null; +} + +function getComposerDraftState( + state: Pick, + target: ComposerThreadTarget, +): ComposerThreadDraftState | null { + const threadKey = resolveComposerDraftKey(state, target); + if (!threadKey) { + return null; + } + return state.draftsByThreadKey[threadKey] ?? null; +} + +function isComposerThreadKeyInUse(mappings: Record, threadKey: string): boolean { + return Object.values(mappings).includes(threadKey); +} + +function toProjectDraftSession( + draftId: DraftId, + draftSession: DraftSessionState, +): ProjectDraftSession { + return { + draftId, + ...draftSession, + }; +} + +function createDraftThreadState( + projectRef: ScopedProjectRef, + threadId: ThreadId, + logicalProjectKey: string, + existingThread: DraftThreadState | undefined, + options?: { + threadId?: ThreadId; + branch?: string | null; + worktreePath?: string | null; + createdAt?: string; + envMode?: DraftThreadEnvMode; + runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; + }, +): DraftThreadState { + const nextWorktreePath = + options?.worktreePath === undefined + ? (existingThread?.worktreePath ?? null) + : (options.worktreePath ?? null); + return { + threadId, + environmentId: projectRef.environmentId, + projectId: projectRef.projectId, + logicalProjectKey, + createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), + runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: + options?.interactionMode ?? existingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, + branch: + options?.branch === undefined ? (existingThread?.branch ?? null) : (options.branch ?? null), + worktreePath: nextWorktreePath, + envMode: + options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), + promotedTo: null, + }; +} + +function scopedThreadRefsEqual( + left: ScopedThreadRef | null | undefined, + right: ScopedThreadRef | null | undefined, +): boolean { + if (!left || !right) { + return left === right; + } + return left.environmentId === right.environmentId && left.threadId === right.threadId; +} + +function isDraftThreadPromoting(draftThread: DraftThreadState | null | undefined): boolean { + return draftThread?.promotedTo !== null && draftThread?.promotedTo !== undefined; +} + +function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThreadState): boolean { + return ( + !!left && + left.threadId === right.threadId && + left.environmentId === right.environmentId && + left.projectId === right.projectId && + left.logicalProjectKey === right.logicalProjectKey && + left.createdAt === right.createdAt && + left.runtimeMode === right.runtimeMode && + left.interactionMode === right.interactionMode && + left.branch === right.branch && + left.worktreePath === right.worktreePath && + left.envMode === right.envMode && + scopedThreadRefsEqual(left.promotedTo, right.promotedTo) + ); +} + +function removeDraftThreadReferences( + state: Pick< + ComposerDraftStoreState, + | "draftThreadsByThreadKey" + | "draftsByThreadKey" + | "logicalProjectDraftThreadKeyByLogicalProjectKey" + >, + threadKey: string, +): Pick< + ComposerDraftStoreState, + | "draftThreadsByThreadKey" + | "draftsByThreadKey" + | "logicalProjectDraftThreadKeyByLogicalProjectKey" +> { + const nextLogicalMappings = Object.fromEntries( + Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( + ([, draftThreadKey]) => draftThreadKey !== threadKey, + ), + ) as Record; + const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } = + state.draftThreadsByThreadKey; + const { [threadKey]: _removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; + return { + draftsByThreadKey: restDraftsByThreadKey, + draftThreadsByThreadKey: restDraftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, + }; +} + function normalizePersistedDraftThreads( rawDraftThreadsByThreadId: unknown, - rawProjectDraftThreadIdByProjectId: unknown, + rawProjectDraftThreadIdByProjectKey: unknown, ): Pick< PersistedComposerDraftStoreState, - "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId" + "draftThreadsByThreadKey" | "logicalProjectDraftThreadKeyByLogicalProjectKey" > { - const draftThreadsByThreadId: Record = {}; + const draftThreadsByThreadKey: Record = {}; + const environmentIdByThreadId = new Map(); + if ( + rawProjectDraftThreadIdByProjectKey && + typeof rawProjectDraftThreadIdByProjectKey === "object" + ) { + for (const [projectKey, threadId] of Object.entries( + rawProjectDraftThreadIdByProjectKey as Record, + )) { + if (typeof threadId !== "string" || threadId.length === 0) { + continue; + } + const projectRef = parseScopedProjectKey(projectKey); + if (!projectRef) { + continue; + } + const parsedThreadRef = parseScopedThreadKey(threadId); + if (parsedThreadRef) { + environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId); + continue; + } + environmentIdByThreadId.set(threadId as ThreadId, projectRef.environmentId); + } + } if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { - for (const [threadId, rawDraftThread] of Object.entries( + for (const [threadKeyOrId, rawDraftThread] of Object.entries( rawDraftThreadsByThreadId as Record, )) { - if (typeof threadId !== "string" || threadId.length === 0) { + if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) { continue; } if (!rawDraftThread || typeof rawDraftThread !== "object") { continue; } const candidateDraftThread = rawDraftThread as Record; + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); + const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId); + const threadId = + parsedThreadRef?.threadId ?? + (typeof candidateDraftThread.threadId === "string" && + candidateDraftThread.threadId.length > 0 + ? (candidateDraftThread.threadId as ThreadId) + : (threadKeyOrId as ThreadId)); + const environmentId = + parsedThreadRef?.environmentId ?? + (typeof candidateDraftThread.environmentId === "string" && + candidateDraftThread.environmentId.length > 0 + ? (candidateDraftThread.environmentId as EnvironmentId) + : environmentIdByThreadId.get(threadKeyOrId as ThreadId)); const projectId = candidateDraftThread.projectId; const createdAt = candidateDraftThread.createdAt; const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; - if (typeof projectId !== "string" || projectId.length === 0) { + const promotedToCandidate = candidateDraftThread.promotedTo; + const promotedToRecord = + promotedToCandidate && typeof promotedToCandidate === "object" + ? (promotedToCandidate as Record) + : null; + const promotedTo = + promotedToRecord && + typeof promotedToRecord.environmentId === "string" && + promotedToRecord.environmentId.length > 0 && + typeof promotedToRecord.threadId === "string" && + promotedToRecord.threadId.length > 0 + ? scopeThreadRef( + promotedToRecord.environmentId as EnvironmentId, + promotedToRecord.threadId as ThreadId, + ) + : null; + if (typeof projectId !== "string" || projectId.length === 0 || environmentId === undefined) { continue; } - draftThreadsByThreadId[threadId as ThreadId] = { + const normalizedEnvironmentId = environmentId as EnvironmentId; + draftThreadsByThreadKey[threadKey] = { + threadId, + environmentId: normalizedEnvironmentId, projectId: projectId as ProjectId, + logicalProjectKey: + typeof candidateDraftThread.logicalProjectKey === "string" && + candidateDraftThread.logicalProjectKey.length > 0 + ? candidateDraftThread.logicalProjectKey + : parsedThreadRef + ? projectDraftKey(scopeProjectRef(normalizedEnvironmentId, projectId as ProjectId)) + : threadKeyOrId, createdAt: typeof createdAt === "string" && createdAt.length > 0 ? createdAt @@ -794,59 +1192,97 @@ function normalizePersistedDraftThreads( branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), + promotedTo, }; } } - const projectDraftThreadIdByProjectId: Record = {}; + const logicalProjectDraftThreadKeyByLogicalProjectKey: Record = {}; if ( - rawProjectDraftThreadIdByProjectId && - typeof rawProjectDraftThreadIdByProjectId === "object" + rawProjectDraftThreadIdByProjectKey && + typeof rawProjectDraftThreadIdByProjectKey === "object" ) { - for (const [projectId, threadId] of Object.entries( - rawProjectDraftThreadIdByProjectId as Record, + for (const [logicalProjectKey, threadKeyOrId] of Object.entries( + rawProjectDraftThreadIdByProjectKey as Record, )) { - if ( - typeof projectId === "string" && - projectId.length > 0 && - typeof threadId === "string" && - threadId.length > 0 - ) { - projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId; - if (!draftThreadsByThreadId[threadId as ThreadId]) { - draftThreadsByThreadId[threadId as ThreadId] = { - projectId: projectId as ProjectId, - createdAt: new Date().toISOString(), - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, - branch: null, - worktreePath: null, - envMode: "local", - }; - } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) { - draftThreadsByThreadId[threadId as ThreadId] = { - ...draftThreadsByThreadId[threadId as ThreadId]!, - projectId: projectId as ProjectId, + if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) { + continue; + } + const projectRef = parseScopedProjectKey(logicalProjectKey); + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); + const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId); + logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey; + if (parsedThreadRef) { + environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId); + } + if (!projectRef) { + const existingDraftThread = draftThreadsByThreadKey[threadKey]; + if (existingDraftThread && !existingDraftThread.logicalProjectKey) { + draftThreadsByThreadKey[threadKey] = { + ...existingDraftThread, + logicalProjectKey, }; } + continue; + } + if (!draftThreadsByThreadKey[threadKey]) { + draftThreadsByThreadKey[threadKey] = { + threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId), + environmentId: projectRef.environmentId, + projectId: projectRef.projectId, + logicalProjectKey, + createdAt: new Date().toISOString(), + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + branch: null, + worktreePath: null, + envMode: "local", + promotedTo: null, + }; + } else if ( + draftThreadsByThreadKey[threadKey]?.projectId !== projectRef.projectId || + draftThreadsByThreadKey[threadKey]?.environmentId !== projectRef.environmentId + ) { + draftThreadsByThreadKey[threadKey] = { + ...draftThreadsByThreadKey[threadKey]!, + threadId: draftThreadsByThreadKey[threadKey]!.threadId, + environmentId: projectRef.environmentId, + projectId: projectRef.projectId, + logicalProjectKey, + }; } } } - return { draftThreadsByThreadId, projectDraftThreadIdByProjectId }; + return { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey }; } function normalizePersistedDraftsByThreadId( rawDraftMap: unknown, -): PersistedComposerDraftStoreState["draftsByThreadId"] { + draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"], +): PersistedComposerDraftStoreState["draftsByThreadKey"] { if (!rawDraftMap || typeof rawDraftMap !== "object") { return {}; } - const nextDraftsByThreadId: DeepMutable = + const environmentIdByThreadId = new Map(); + for (const [threadKey, draftThread] of Object.entries(draftThreadsByThreadKey)) { + const parsedThreadRef = composerThreadRefFromKey(threadKey); + if (!parsedThreadRef) { + continue; + } + environmentIdByThreadId.set( + parsedThreadRef.threadId, + draftThread.environmentId as EnvironmentId, + ); + } + + const nextDraftsByThreadKey: DeepMutable = {}; - for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record)) { - if (typeof threadId !== "string" || threadId.length === 0) { + for (const [threadKeyOrId, draftValue] of Object.entries( + rawDraftMap as Record, + )) { + if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) { continue; } if (!draftValue || typeof draftValue !== "object") { @@ -937,7 +1373,19 @@ function normalizePersistedDraftsByThreadId( ) { continue; } - nextDraftsByThreadId[threadId as ThreadId] = { + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); + const normalizedThreadKey = + parsedThreadRef !== null + ? normalizeLegacyComposerStorageKey(threadKeyOrId) + : draftThreadsByThreadKey[threadKeyOrId] !== undefined + ? threadKeyOrId + : (() => { + const environmentId = environmentIdByThreadId.get(threadKeyOrId as ThreadId); + return environmentId + ? normalizeLegacyComposerStorageKey(threadKeyOrId, { environmentId }) + : threadKeyOrId; + })(); + nextDraftsByThreadKey[normalizedThreadKey] = { prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), @@ -947,7 +1395,7 @@ function normalizePersistedDraftsByThreadId( }; } - return nextDraftsByThreadId; + return nextDraftsByThreadKey; } function migratePersistedComposerDraftStoreState( @@ -957,9 +1405,14 @@ function migratePersistedComposerDraftStoreState( return EMPTY_PERSISTED_DRAFT_STORE_STATE; } const candidate = persistedState as LegacyPersistedComposerDraftStoreState; - const rawDraftMap = candidate.draftsByThreadId; - const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; - const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; + const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId; + const rawDraftThreadsByThreadId = + candidate.draftThreadsByThreadKey ?? candidate.draftThreadsByThreadId; + const rawProjectDraftThreadIdByProjectKey = + candidate.logicalProjectDraftThreadKeyByLogicalProjectKey ?? + candidate.projectDraftThreadKeyByProjectKey ?? + candidate.projectDraftThreadIdByProjectKey ?? + candidate.projectDraftThreadIdByProjectId; // Migrate sticky state from v2 (dual) to v3 (consolidated) const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; @@ -982,13 +1435,16 @@ function migratePersistedComposerDraftStoreState( ); const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null; - const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = - normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); - const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap); + const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = + normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey); + const draftsByThreadKey = normalizePersistedDraftsByThreadId( + rawDraftMap, + draftThreadsByThreadKey, + ); return { - draftsByThreadId, - draftThreadsByThreadId, - projectDraftThreadIdByProjectId, + draftsByThreadKey, + draftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider, stickyActiveProvider, }; @@ -997,11 +1453,11 @@ function migratePersistedComposerDraftStoreState( function partializeComposerDraftStoreState( state: ComposerDraftStoreState, ): PersistedComposerDraftStoreState { - const persistedDraftsByThreadId: DeepMutable< - PersistedComposerDraftStoreState["draftsByThreadId"] + const persistedDraftsByThreadKey: DeepMutable< + PersistedComposerDraftStoreState["draftsByThreadKey"] > = {}; - for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { - if (typeof threadId !== "string" || threadId.length === 0) { + for (const [threadKey, draft] of Object.entries(state.draftsByThreadKey)) { + if (typeof threadKey !== "string" || threadKey.length === 0) { continue; } const hasModelData = @@ -1041,12 +1497,13 @@ function partializeComposerDraftStoreState( ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), }; - persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft; + persistedDraftsByThreadKey[threadKey] = persistedDraft; } return { - draftsByThreadId: persistedDraftsByThreadId, - draftThreadsByThreadId: state.draftThreadsByThreadId, - projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, + draftsByThreadKey: persistedDraftsByThreadKey, + draftThreadsByThreadKey: state.draftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey: + state.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: state.stickyModelSelectionByProvider, stickyActiveProvider: state.stickyActiveProvider, }; @@ -1059,10 +1516,14 @@ function normalizeCurrentPersistedComposerDraftStoreState( return EMPTY_PERSISTED_DRAFT_STORE_STATE; } const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState; - const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = + const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = normalizePersistedDraftThreads( - normalizedPersistedState.draftThreadsByThreadId, - normalizedPersistedState.projectDraftThreadIdByProjectId, + normalizedPersistedState.draftThreadsByThreadKey ?? + normalizedPersistedState.draftThreadsByThreadId, + normalizedPersistedState.logicalProjectDraftThreadKeyByLogicalProjectKey ?? + normalizedPersistedState.projectDraftThreadKeyByProjectKey ?? + normalizedPersistedState.projectDraftThreadIdByProjectKey ?? + normalizedPersistedState.projectDraftThreadIdByProjectId, ); // Handle both v3 (modelSelectionByProvider) and v2/legacy formats @@ -1105,16 +1566,19 @@ function normalizeCurrentPersistedComposerDraftStoreState( } return { - draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId), - draftThreadsByThreadId, - projectDraftThreadIdByProjectId, + draftsByThreadKey: normalizePersistedDraftsByThreadId( + normalizedPersistedState.draftsByThreadKey ?? normalizedPersistedState.draftsByThreadId, + draftThreadsByThreadKey, + ), + draftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider, stickyActiveProvider, }; } -function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { - if (threadId.length === 0) { +function readPersistedAttachmentIdsFromStorage(threadKey: string): string[] { + if (threadKey.length === 0) { return []; } try { @@ -1125,7 +1589,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) { return []; } - return (persisted.state.draftsByThreadId[threadId]?.attachments ?? []).map( + return (persisted.state.draftsByThreadKey[threadKey]?.attachments ?? []).map( (attachment) => attachment.id, ); } catch { @@ -1134,7 +1598,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { } function verifyPersistedAttachments( - threadId: ThreadId, + threadKey: string, attachments: PersistedComposerImageAttachment[], set: ( partial: @@ -1149,12 +1613,12 @@ function verifyPersistedAttachments( let persistedIdSet = new Set(); try { composerDebouncedStorage.flush(); - persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId)); + persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadKey)); } catch { persistedIdSet = new Set(); } set((state) => { - const current = state.draftsByThreadId[threadId]; + const current = state.draftsByThreadKey[threadKey]; if (!current) { return state; } @@ -1170,13 +1634,13 @@ function verifyPersistedAttachments( persistedAttachments, nonPersistedImageIds, }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; + delete nextDraftsByThreadKey[threadKey]; } else { - nextDraftsByThreadId[threadId] = nextDraft; + nextDraftsByThreadKey[threadKey] = nextDraft; } - return { draftsByThreadId: nextDraftsByThreadId }; + return { draftsByThreadKey: nextDraftsByThreadKey }; }); } @@ -1258,879 +1722,980 @@ function toHydratedThreadDraft( }; } -export const useComposerDraftStore = create()( +function toHydratedDraftThreadState( + persistedDraftThread: PersistedDraftThreadState, +): DraftThreadState { + return { + threadId: persistedDraftThread.threadId, + environmentId: persistedDraftThread.environmentId as EnvironmentId, + projectId: persistedDraftThread.projectId, + logicalProjectKey: + persistedDraftThread.logicalProjectKey ?? + projectDraftKey( + scopeProjectRef( + persistedDraftThread.environmentId as EnvironmentId, + persistedDraftThread.projectId, + ), + ), + createdAt: persistedDraftThread.createdAt, + runtimeMode: persistedDraftThread.runtimeMode, + interactionMode: persistedDraftThread.interactionMode, + branch: persistedDraftThread.branch, + worktreePath: persistedDraftThread.worktreePath, + envMode: persistedDraftThread.envMode, + promotedTo: persistedDraftThread.promotedTo + ? scopeThreadRef( + persistedDraftThread.promotedTo.environmentId as EnvironmentId, + persistedDraftThread.promotedTo.threadId as ThreadId, + ) + : null, + }; +} + +const composerDraftStore = create()( persist( - (set, get) => ({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - stickyModelSelectionByProvider: {}, - stickyActiveProvider: null, - getDraftThreadByProjectId: (projectId) => { - if (projectId.length === 0) { - return null; - } - const threadId = get().projectDraftThreadIdByProjectId[projectId]; - if (!threadId) { - return null; - } - const draftThread = get().draftThreadsByThreadId[threadId]; - if (!draftThread || draftThread.projectId !== projectId) { - return null; - } - return { - threadId, - ...draftThread, - }; - }, - getDraftThread: (threadId) => { - if (threadId.length === 0) { - return null; - } - return get().draftThreadsByThreadId[threadId] ?? null; - }, - setProjectDraftThreadId: (projectId, threadId, options) => { - if (projectId.length === 0 || threadId.length === 0) { - return; - } - set((state) => { - const existingThread = state.draftThreadsByThreadId[threadId]; - const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId]; - const nextWorktreePath = - options?.worktreePath === undefined - ? (existingThread?.worktreePath ?? null) - : (options.worktreePath ?? null); - const nextDraftThread: DraftThreadState = { - projectId, - createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), - runtimeMode: - options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, - interactionMode: - options?.interactionMode ?? - existingThread?.interactionMode ?? - DEFAULT_INTERACTION_MODE, - branch: - options?.branch === undefined - ? (existingThread?.branch ?? null) - : (options.branch ?? null), - worktreePath: nextWorktreePath, - envMode: - options?.envMode ?? - (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), - }; - const hasSameProjectMapping = previousThreadIdForProject === threadId; - const hasSameDraftThread = - existingThread && - existingThread.projectId === nextDraftThread.projectId && - existingThread.createdAt === nextDraftThread.createdAt && - existingThread.runtimeMode === nextDraftThread.runtimeMode && - existingThread.interactionMode === nextDraftThread.interactionMode && - existingThread.branch === nextDraftThread.branch && - existingThread.worktreePath === nextDraftThread.worktreePath && - existingThread.envMode === nextDraftThread.envMode; - if (hasSameProjectMapping && hasSameDraftThread) { - return state; - } - const nextProjectDraftThreadIdByProjectId: Record = { - ...state.projectDraftThreadIdByProjectId, - [projectId]: threadId, - }; - const nextDraftThreadsByThreadId: Record = { - ...state.draftThreadsByThreadId, - [threadId]: nextDraftThread, - }; - let nextDraftsByThreadId = state.draftsByThreadId; - if ( - previousThreadIdForProject && - previousThreadIdForProject !== threadId && - !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject) - ) { - delete nextDraftThreadsByThreadId[previousThreadIdForProject]; - if (state.draftsByThreadId[previousThreadIdForProject] !== undefined) { - nextDraftsByThreadId = { ...state.draftsByThreadId }; - delete nextDraftsByThreadId[previousThreadIdForProject]; - } - } - return { - draftsByThreadId: nextDraftsByThreadId, - draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, - }; - }); - }, - setDraftThreadContext: (threadId, options) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const existing = state.draftThreadsByThreadId[threadId]; - if (!existing) { - return state; + (setBase, get) => { + const set = setBase; + + return { + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + getComposerDraft: (target) => getComposerDraftState(get(), target), + getDraftThreadByLogicalProjectKey: (logicalProjectKey) => { + return get().getDraftSessionByLogicalProjectKey(logicalProjectKey); + }, + getDraftSessionByLogicalProjectKey: (logicalProjectKey) => { + const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey); + if (normalizedLogicalProjectKey.length === 0) { + return null; } - const nextProjectId = options.projectId ?? existing.projectId; - if (nextProjectId.length === 0) { - return state; + const draftId = + get().logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey]; + if (!draftId) { + return null; } - const nextWorktreePath = - options.worktreePath === undefined - ? existing.worktreePath - : (options.worktreePath ?? null); - const nextDraftThread: DraftThreadState = { - projectId: nextProjectId, - createdAt: - options.createdAt === undefined - ? existing.createdAt - : options.createdAt || existing.createdAt, - runtimeMode: options.runtimeMode ?? existing.runtimeMode, - interactionMode: options.interactionMode ?? existing.interactionMode, - branch: options.branch === undefined ? existing.branch : (options.branch ?? null), - worktreePath: nextWorktreePath, - envMode: - options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), - }; - const isUnchanged = - nextDraftThread.projectId === existing.projectId && - nextDraftThread.createdAt === existing.createdAt && - nextDraftThread.runtimeMode === existing.runtimeMode && - nextDraftThread.interactionMode === existing.interactionMode && - nextDraftThread.branch === existing.branch && - nextDraftThread.worktreePath === existing.worktreePath && - nextDraftThread.envMode === existing.envMode; - if (isUnchanged) { - return state; + const draftThread = get().draftThreadsByThreadKey[draftId]; + if (!draftThread || isDraftThreadPromoting(draftThread)) { + return null; } - const nextProjectDraftThreadIdByProjectId: Record = { - ...state.projectDraftThreadIdByProjectId, - [nextProjectId]: threadId, - }; - if (existing.projectId !== nextProjectId) { - if (nextProjectDraftThreadIdByProjectId[existing.projectId] === threadId) { - delete nextProjectDraftThreadIdByProjectId[existing.projectId]; + return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread); + }, + getDraftThreadByProjectRef: (projectRef) => { + return get().getDraftSessionByProjectRef(projectRef); + }, + getDraftSessionByProjectRef: (projectRef) => { + for (const [draftId, draftThread] of Object.entries(get().draftThreadsByThreadKey)) { + if (isDraftThreadPromoting(draftThread)) { + continue; } - } - return { - draftThreadsByThreadId: { - ...state.draftThreadsByThreadId, - [threadId]: nextDraftThread, - }, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, - }; - }); - }, - clearProjectDraftThreadId: (projectId) => { - if (projectId.length === 0) { - return; - } - set((state) => { - const threadId = state.projectDraftThreadIdByProjectId[projectId]; - if (threadId === undefined) { - return state; - } - const { [projectId]: _removed, ...restProjectMappingsRaw } = - state.projectDraftThreadIdByProjectId; - const restProjectMappings = restProjectMappingsRaw as Record; - const nextDraftThreadsByThreadId: Record = { - ...state.draftThreadsByThreadId, - }; - let nextDraftsByThreadId = state.draftsByThreadId; - if (!Object.values(restProjectMappings).includes(threadId)) { - delete nextDraftThreadsByThreadId[threadId]; - if (state.draftsByThreadId[threadId] !== undefined) { - nextDraftsByThreadId = { ...state.draftsByThreadId }; - delete nextDraftsByThreadId[threadId]; + if ( + draftThread.projectId === projectRef.projectId && + draftThread.environmentId === projectRef.environmentId + ) { + return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread); } } - return { - draftsByThreadId: nextDraftsByThreadId, - draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: restProjectMappings, - }; - }); - }, - clearProjectDraftThreadById: (projectId, threadId) => { - if (projectId.length === 0 || threadId.length === 0) { - return; - } - set((state) => { - if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) { - return state; - } - const { [projectId]: _removed, ...restProjectMappingsRaw } = - state.projectDraftThreadIdByProjectId; - const restProjectMappings = restProjectMappingsRaw as Record; - const nextDraftThreadsByThreadId: Record = { - ...state.draftThreadsByThreadId, - }; - let nextDraftsByThreadId = state.draftsByThreadId; - if (!Object.values(restProjectMappings).includes(threadId)) { - delete nextDraftThreadsByThreadId[threadId]; - if (state.draftsByThreadId[threadId] !== undefined) { - nextDraftsByThreadId = { ...state.draftsByThreadId }; - delete nextDraftsByThreadId[threadId]; + return null; + }, + getDraftSession: (draftId) => get().draftThreadsByThreadKey[draftId] ?? null, + getDraftSessionByRef: (threadRef) => { + for (const draftSession of Object.values(get().draftThreadsByThreadKey)) { + if ( + draftSession.environmentId === threadRef.environmentId && + draftSession.threadId === threadRef.threadId + ) { + return draftSession; } } - return { - draftsByThreadId: nextDraftsByThreadId, - draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: restProjectMappings, - }; - }); - }, - clearDraftThread: (threadId) => { - if (threadId.length === 0) { - return; - } - const existing = get().draftsByThreadId[threadId]; - if (existing) { - for (const image of existing.images) { - revokeObjectPreviewUrl(image.previewUrl); - } - } - set((state) => { - const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; - const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( - threadId, - ); - const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; - if (!hasDraftThread && !hasProjectMapping && !hasComposerDraft) { - return state; - } - const nextProjectDraftThreadIdByProjectId = Object.fromEntries( - Object.entries(state.projectDraftThreadIdByProjectId).filter( - ([, draftThreadId]) => draftThreadId !== threadId, - ), - ) as Record; - const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = - state.draftThreadsByThreadId; - const { [threadId]: _removedComposerDraft, ...restDraftsByThreadId } = - state.draftsByThreadId; - return { - draftsByThreadId: restDraftsByThreadId, - draftThreadsByThreadId: restDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, - }; - }); - }, - setStickyModelSelection: (modelSelection) => { - const normalized = normalizeModelSelection(modelSelection); - set((state) => { - if (!normalized) { - return state; + return null; + }, + getDraftThread: (threadRef) => { + if (typeof threadRef === "string") { + return get().getDraftSession(DraftId.makeUnsafe(threadRef)); } - const nextMap: Partial> = { - ...state.stickyModelSelectionByProvider, - [normalized.provider]: normalized, - }; - if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { - return state.stickyActiveProvider === normalized.provider - ? state - : { stickyActiveProvider: normalized.provider }; + return get().getDraftSessionByRef(threadRef); + }, + getDraftThreadByRef: (threadRef) => { + return get().getDraftSessionByRef(threadRef); + }, + listDraftThreadKeys: () => + Object.values(get().draftThreadsByThreadKey).map((draftThread) => + scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), + ), + hasDraftThreadsInEnvironment: (environmentId) => + Object.values(get().draftThreadsByThreadKey).some( + (draftThread) => draftThread.environmentId === environmentId, + ), + setLogicalProjectDraftThreadId: (logicalProjectKey, projectRef, draftId, options) => { + const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey); + if (normalizedLogicalProjectKey.length === 0 || draftId.length === 0) { + return; } - return { - stickyModelSelectionByProvider: nextMap, - stickyActiveProvider: normalized.provider, - }; - }); - }, - applyStickyState: (threadId) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const stickyMap = state.stickyModelSelectionByProvider; - const stickyActiveProvider = state.stickyActiveProvider; - if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { - return state; + set((state) => { + const existingThread = state.draftThreadsByThreadKey[draftId]; + const previousThreadKeyForLogicalProject = + state.logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey]; + const nextDraftThread = createDraftThreadState( + projectRef, + options?.threadId ?? existingThread?.threadId ?? ThreadId.makeUnsafe(draftId), + normalizedLogicalProjectKey, + existingThread, + options, + ); + const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId; + if (hasSameLogicalMapping && draftThreadsEqual(existingThread, nextDraftThread)) { + return state; + } + const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record = { + ...state.logicalProjectDraftThreadKeyByLogicalProjectKey, + [normalizedLogicalProjectKey]: draftId, + }; + const nextDraftThreadsByThreadKey: Record = { + ...state.draftThreadsByThreadKey, + [draftId]: nextDraftThread, + }; + let nextDraftsByThreadKey = state.draftsByThreadKey; + if ( + previousThreadKeyForLogicalProject && + previousThreadKeyForLogicalProject !== draftId && + !isComposerThreadKeyInUse( + nextLogicalProjectDraftThreadKeyByLogicalProjectKey, + previousThreadKeyForLogicalProject, + ) + ) { + delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; + if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { + nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject]; + } + } + return { + draftsByThreadKey: nextDraftsByThreadKey, + draftThreadsByThreadKey: nextDraftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey: + nextLogicalProjectDraftThreadKeyByLogicalProjectKey, + }; + }); + }, + setProjectDraftThreadId: (projectRef, draftId, options) => { + get().setLogicalProjectDraftThreadId( + projectDraftKey(projectRef), + projectRef, + draftId, + options, + ); + }, + setDraftThreadContext: (threadRef, options) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const existing = state.draftsByThreadId[threadId]; - const base = existing ?? createEmptyThreadDraft(); - const nextMap = { ...base.modelSelectionByProvider }; - for (const [provider, selection] of Object.entries(stickyMap)) { - if (selection) { - const current = nextMap[provider as ProviderKind]; - nextMap[provider as ProviderKind] = { - ...selection, - model: current?.model ?? selection.model, - }; + set((state) => { + const existing = state.draftThreadsByThreadKey[threadKey]; + if (!existing) { + return state; } + const nextProjectRef = options.projectRef ?? { + environmentId: existing.environmentId, + projectId: existing.projectId, + }; + if ( + nextProjectRef.projectId.length === 0 || + nextProjectRef.environmentId.length === 0 + ) { + return state; + } + const nextWorktreePath = + options.worktreePath === undefined + ? existing.worktreePath + : (options.worktreePath ?? null); + const nextDraftThread: DraftThreadState = { + threadId: existing.threadId, + environmentId: nextProjectRef.environmentId, + projectId: nextProjectRef.projectId, + logicalProjectKey: existing.logicalProjectKey, + createdAt: + options.createdAt === undefined + ? existing.createdAt + : options.createdAt || existing.createdAt, + runtimeMode: options.runtimeMode ?? existing.runtimeMode, + interactionMode: options.interactionMode ?? existing.interactionMode, + branch: options.branch === undefined ? existing.branch : (options.branch ?? null), + worktreePath: nextWorktreePath, + envMode: + options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), + promotedTo: existing.promotedTo ?? null, + }; + const isUnchanged = + nextDraftThread.environmentId === existing.environmentId && + nextDraftThread.projectId === existing.projectId && + nextDraftThread.logicalProjectKey === existing.logicalProjectKey && + nextDraftThread.createdAt === existing.createdAt && + nextDraftThread.runtimeMode === existing.runtimeMode && + nextDraftThread.interactionMode === existing.interactionMode && + nextDraftThread.branch === existing.branch && + nextDraftThread.worktreePath === existing.worktreePath && + nextDraftThread.envMode === existing.envMode && + scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); + if (isUnchanged) { + return state; + } + return { + draftThreadsByThreadKey: { + ...state.draftThreadsByThreadKey, + [threadKey]: nextDraftThread, + }, + }; + }); + }, + clearProjectDraftThreadId: (projectRef) => { + set((state) => { + const matchingThreadEntry = Object.entries(state.draftThreadsByThreadKey).find( + ([, draftThread]) => + draftThread.projectId === projectRef.projectId && + draftThread.environmentId === projectRef.environmentId, + ); + if (!matchingThreadEntry) { + return state; + } + return removeDraftThreadReferences(state, matchingThreadEntry[0]); + }); + }, + clearProjectDraftThreadById: (projectRef, threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - if ( - Equal.equals(base.modelSelectionByProvider, nextMap) && - base.activeProvider === stickyActiveProvider - ) { - return state; + set((state) => { + const draftThread = state.draftThreadsByThreadKey[threadKey]; + if ( + !draftThread || + draftThread.projectId !== projectRef.projectId || + draftThread.environmentId !== projectRef.environmentId + ) { + return state; + } + return removeDraftThreadReferences(state, threadKey); + }); + }, + markDraftThreadPromoting: (threadRef, promotedTo) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...base, - modelSelectionByProvider: nextMap, - activeProvider: stickyActiveProvider, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + set((state) => { + const existing = state.draftThreadsByThreadKey[threadKey]; + if (!existing) { + return state; + } + const nextPromotedTo = + promotedTo ?? scopeThreadRef(existing.environmentId, existing.threadId); + if (scopedThreadRefsEqual(existing.promotedTo, nextPromotedTo)) { + return state; + } + return { + draftThreadsByThreadKey: { + ...state.draftThreadsByThreadKey, + [threadKey]: { + ...existing, + promotedTo: nextPromotedTo, + }, + }, + }; + }); + }, + finalizePromotedDraftThread: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setPrompt: (threadId, prompt) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); - const nextDraft: ComposerThreadDraftState = { - ...existing, - prompt, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + set((state) => { + const existing = state.draftThreadsByThreadKey[threadKey]; + if (!isDraftThreadPromoting(existing)) { + return state; + } + return removeDraftThreadReferences(state, threadKey); + }); + }, + clearDraftThread: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setTerminalContexts: (threadId, contexts) => { - if (threadId.length === 0) { - return; - } - const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts); - set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); - const nextDraft: ComposerThreadDraftState = { - ...existing, - prompt: ensureInlineTerminalContextPlaceholders( - existing.prompt, - normalizedContexts.length, - ), - terminalContexts: normalizedContexts, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + const existing = get().draftsByThreadKey[threadKey]; + if (existing) { + for (const image of existing.images) { + revokeObjectPreviewUrl(image.previewUrl); + } } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setModelSelection: (threadId, modelSelection) => { - if (threadId.length === 0) { - return; - } - const normalized = normalizeModelSelection(modelSelection); - set((state) => { - const existing = state.draftsByThreadId[threadId]; - if (!existing && normalized === null) { - return state; + set((state) => { + const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined; + const hasLogicalProjectMapping = Object.values( + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + ).includes(threadKey); + const hasComposerDraft = state.draftsByThreadKey[threadKey] !== undefined; + if (!hasDraftThread && !hasLogicalProjectMapping && !hasComposerDraft) { + return state; + } + return removeDraftThreadReferences(state, threadKey); + }); + }, + setStickyModelSelection: (modelSelection) => { + const normalized = normalizeModelSelection(modelSelection); + set((state) => { + if (!normalized) { + return state; + } + const nextMap: Partial> = { + ...state.stickyModelSelectionByProvider, + [normalized.provider]: normalized, + }; + if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { + return state.stickyActiveProvider === normalized.provider + ? state + : { stickyActiveProvider: normalized.provider }; + } + return { + stickyModelSelectionByProvider: nextMap, + stickyActiveProvider: normalized.provider, + }; + }); + }, + applyStickyState: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const base = existing ?? createEmptyThreadDraft(); - const nextMap = { ...base.modelSelectionByProvider }; - if (normalized) { - const current = nextMap[normalized.provider]; - if (normalized.options !== undefined) { - // Explicit options provided → use them - nextMap[normalized.provider] = normalized; + set((state) => { + const stickyMap = state.stickyModelSelectionByProvider; + const stickyActiveProvider = state.stickyActiveProvider; + if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { + return state; + } + const existing = state.draftsByThreadKey[threadKey]; + const base = existing ?? createEmptyThreadDraft(); + const nextMap = { ...base.modelSelectionByProvider }; + for (const [provider, selection] of Object.entries(stickyMap)) { + if (selection) { + const current = nextMap[provider as ProviderKind]; + nextMap[provider as ProviderKind] = { + ...selection, + model: current?.model ?? selection.model, + }; + } + } + if ( + Equal.equals(base.modelSelectionByProvider, nextMap) && + base.activeProvider === stickyActiveProvider + ) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + modelSelectionByProvider: nextMap, + activeProvider: stickyActiveProvider, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; } else { - // No options in selection → preserve existing options, update provider+model - nextMap[normalized.provider] = { - provider: normalized.provider, - model: normalized.model, - ...(current?.options ? { options: current.options } : {}), - }; + nextDraftsByThreadKey[threadKey] = nextDraft; } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setPrompt: (threadRef, prompt) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const nextActiveProvider = normalized?.provider ?? base.activeProvider; - if ( - Equal.equals(base.modelSelectionByProvider, nextMap) && - base.activeProvider === nextActiveProvider - ) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...base, - modelSelectionByProvider: nextMap, - activeProvider: nextActiveProvider, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setModelOptions: (threadId, modelOptions) => { - if (threadId.length === 0) { - return; - } - const normalizedOpts = normalizeProviderModelOptions(modelOptions); - set((state) => { - const existing = state.draftsByThreadId[threadId]; - if (!existing && normalizedOpts === null) { - return state; - } - const base = existing ?? createEmptyThreadDraft(); - const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent"] as const) { - // Only touch providers explicitly present in the input - if (!normalizedOpts || !(provider in normalizedOpts)) continue; - const opts = normalizedOpts[provider]; - const current = nextMap[provider]; - if (opts) { - nextMap[provider] = { - provider, - model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], - options: opts, - }; - } else if (current?.options) { - // Remove options but keep the selection - const { options: _, ...rest } = current; - nextMap[provider] = rest as ModelSelection; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextDraft: ComposerThreadDraftState = { + ...existing, + prompt, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setTerminalContexts: (threadRef, contexts) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) { + return; } - if (Equal.equals(base.modelSelectionByProvider, nextMap)) { - return state; + const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts); + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextDraft: ComposerThreadDraftState = { + ...existing, + prompt: ensureInlineTerminalContextPlaceholders( + existing.prompt, + normalizedContexts.length, + ), + terminalContexts: normalizedContexts, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setModelSelection: (threadRef, modelSelection) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...base, - modelSelectionByProvider: nextMap, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + const normalized = normalizeModelSelection(modelSelection); + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + if (!existing && normalized === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + const nextMap = { ...base.modelSelectionByProvider }; + if (normalized) { + const current = nextMap[normalized.provider]; + if (normalized.options !== undefined) { + // Explicit options provided → use them + nextMap[normalized.provider] = normalized; + } else { + // No options in selection → preserve existing options, update provider+model + nextMap[normalized.provider] = { + provider: normalized.provider, + model: normalized.model, + ...(current?.options ? { options: current.options } : {}), + }; + } + } + const nextActiveProvider = normalized?.provider ?? base.activeProvider; + if ( + Equal.equals(base.modelSelectionByProvider, nextMap) && + base.activeProvider === nextActiveProvider + ) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + modelSelectionByProvider: nextMap, + activeProvider: nextActiveProvider, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setModelOptions: (threadRef, modelOptions) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setProviderModelOptions: (threadId, provider, nextProviderOptions, options) => { - if (threadId.length === 0) { - return; - } - const normalizedProvider = normalizeProviderKind(provider); - if (normalizedProvider === null) { - return; - } - // Normalize just this provider's options - const normalizedOpts = normalizeProviderModelOptions( - { [normalizedProvider]: nextProviderOptions }, - normalizedProvider, - ); - const providerOpts = normalizedOpts?.[normalizedProvider]; - - set((state) => { - const existing = state.draftsByThreadId[threadId]; - const base = existing ?? createEmptyThreadDraft(); - - // Update the map entry for this provider - const nextMap = { ...base.modelSelectionByProvider }; - const currentForProvider = nextMap[normalizedProvider]; - if (providerOpts) { - nextMap[normalizedProvider] = { - provider: normalizedProvider, - model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], - options: providerOpts, + const normalizedOpts = normalizeProviderModelOptions(modelOptions); + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + if (!existing && normalizedOpts === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + const nextMap = { ...base.modelSelectionByProvider }; + for (const provider of ["codex", "claudeAgent"] as const) { + // Only touch providers explicitly present in the input + if (!normalizedOpts || !(provider in normalizedOpts)) continue; + const opts = normalizedOpts[provider]; + const current = nextMap[provider]; + if (opts) { + nextMap[provider] = { + provider, + model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], + options: opts, + }; + } else if (current?.options) { + // Remove options but keep the selection + const { options: _, ...rest } = current; + nextMap[provider] = rest as ModelSelection; + } + } + if (Equal.equals(base.modelSelectionByProvider, nextMap)) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + modelSelectionByProvider: nextMap, }; - } else if (currentForProvider?.options) { - const { options: _, ...rest } = currentForProvider; - nextMap[normalizedProvider] = rest as ModelSelection; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setProviderModelOptions: (threadRef, provider, nextProviderOptions, options) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } + const normalizedProvider = normalizeProviderKind(provider); + if (normalizedProvider === null) { + return; + } + // Normalize just this provider's options + const normalizedOpts = normalizeProviderModelOptions( + { [normalizedProvider]: nextProviderOptions }, + normalizedProvider, + ); + const providerOpts = normalizedOpts?.[normalizedProvider]; - // Handle sticky persistence - let nextStickyMap = state.stickyModelSelectionByProvider; - let nextStickyActiveProvider = state.stickyActiveProvider; - if (options?.persistSticky === true) { - nextStickyMap = { ...state.stickyModelSelectionByProvider }; - const stickyBase = - nextStickyMap[normalizedProvider] ?? - base.modelSelectionByProvider[normalizedProvider] ?? - ({ - provider: normalizedProvider, - model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], - } as ModelSelection); + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + const base = existing ?? createEmptyThreadDraft(); + + // Update the map entry for this provider + const nextMap = { ...base.modelSelectionByProvider }; + const currentForProvider = nextMap[normalizedProvider]; if (providerOpts) { - nextStickyMap[normalizedProvider] = { - ...stickyBase, + nextMap[normalizedProvider] = { provider: normalizedProvider, + model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], options: providerOpts, }; - } else if (stickyBase.options) { - const { options: _, ...rest } = stickyBase; - nextStickyMap[normalizedProvider] = rest as ModelSelection; + } else if (currentForProvider?.options) { + const { options: _, ...rest } = currentForProvider; + nextMap[normalizedProvider] = rest as ModelSelection; } - nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; - } - if ( - Equal.equals(base.modelSelectionByProvider, nextMap) && - Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && - state.stickyActiveProvider === nextStickyActiveProvider - ) { - return state; - } + // Handle sticky persistence + let nextStickyMap = state.stickyModelSelectionByProvider; + let nextStickyActiveProvider = state.stickyActiveProvider; + if (options?.persistSticky === true) { + nextStickyMap = { ...state.stickyModelSelectionByProvider }; + const stickyBase = + nextStickyMap[normalizedProvider] ?? + base.modelSelectionByProvider[normalizedProvider] ?? + ({ + provider: normalizedProvider, + model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], + } as ModelSelection); + if (providerOpts) { + nextStickyMap[normalizedProvider] = { + ...stickyBase, + provider: normalizedProvider, + options: providerOpts, + }; + } else if (stickyBase.options) { + const { options: _, ...rest } = stickyBase; + nextStickyMap[normalizedProvider] = rest as ModelSelection; + } + nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; + } - const nextDraft: ComposerThreadDraftState = { - ...base, - modelSelectionByProvider: nextMap, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } + if ( + Equal.equals(base.modelSelectionByProvider, nextMap) && + Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && + state.stickyActiveProvider === nextStickyActiveProvider + ) { + return state; + } - return { - draftsByThreadId: nextDraftsByThreadId, - ...(options?.persistSticky === true - ? { - stickyModelSelectionByProvider: nextStickyMap, - stickyActiveProvider: nextStickyActiveProvider, - } - : {}), - }; - }); - }, - setRuntimeMode: (threadId, runtimeMode) => { - if (threadId.length === 0) { - return; - } - const nextRuntimeMode = - runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null; - set((state) => { - const existing = state.draftsByThreadId[threadId]; - if (!existing && nextRuntimeMode === null) { - return state; - } - const base = existing ?? createEmptyThreadDraft(); - if (base.runtimeMode === nextRuntimeMode) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...base, - runtimeMode: nextRuntimeMode, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setInteractionMode: (threadId, interactionMode) => { - if (threadId.length === 0) { - return; - } - const nextInteractionMode = - interactionMode === "plan" || interactionMode === "default" ? interactionMode : null; - set((state) => { - const existing = state.draftsByThreadId[threadId]; - if (!existing && nextInteractionMode === null) { - return state; + const nextDraft: ComposerThreadDraftState = { + ...base, + modelSelectionByProvider: nextMap, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + + return { + draftsByThreadKey: nextDraftsByThreadKey, + ...(options?.persistSticky === true + ? { + stickyModelSelectionByProvider: nextStickyMap, + stickyActiveProvider: nextStickyActiveProvider, + } + : {}), + }; + }); + }, + setRuntimeMode: (threadRef, runtimeMode) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const base = existing ?? createEmptyThreadDraft(); - if (base.interactionMode === nextInteractionMode) { - return state; + const nextRuntimeMode = + runtimeMode === "approval-required" || runtimeMode === "full-access" + ? runtimeMode + : null; + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + if (!existing && nextRuntimeMode === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + if (base.runtimeMode === nextRuntimeMode) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + runtimeMode: nextRuntimeMode, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setInteractionMode: (threadRef, interactionMode) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...base, - interactionMode: nextInteractionMode, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + const nextInteractionMode = + interactionMode === "plan" || interactionMode === "default" ? interactionMode : null; + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + if (!existing && nextInteractionMode === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + if (base.interactionMode === nextInteractionMode) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + interactionMode: nextInteractionMode, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + addImage: (threadRef, image) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - addImage: (threadId, image) => { - if (threadId.length === 0) { - return; - } - get().addImages(threadId, [image]); - }, - addImages: (threadId, images) => { - if (threadId.length === 0 || images.length === 0) { - return; - } - set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); - const existingIds = new Set(existing.images.map((image) => image.id)); - const existingDedupKeys = new Set( - existing.images.map((image) => composerImageDedupKey(image)), + get().addImages( + typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef, + [image], ); - const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl)); - const dedupedIncoming: ComposerImageAttachment[] = []; - for (const image of images) { - const dedupKey = composerImageDedupKey(image); - if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) { - // Avoid revoking a blob URL that's still referenced by an accepted image. - if (!acceptedPreviewUrls.has(image.previewUrl)) { - revokeObjectPreviewUrl(image.previewUrl); + }, + addImages: (threadRef, images) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0 || images.length === 0) { + return; + } + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const existingIds = new Set(existing.images.map((image) => image.id)); + const existingDedupKeys = new Set( + existing.images.map((image) => composerImageDedupKey(image)), + ); + const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl)); + const dedupedIncoming: ComposerImageAttachment[] = []; + for (const image of images) { + const dedupKey = composerImageDedupKey(image); + if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) { + // Avoid revoking a blob URL that's still referenced by an accepted image. + if (!acceptedPreviewUrls.has(image.previewUrl)) { + revokeObjectPreviewUrl(image.previewUrl); + } + continue; } - continue; + dedupedIncoming.push(image); + existingIds.add(image.id); + existingDedupKeys.add(dedupKey); + acceptedPreviewUrls.add(image.previewUrl); } - dedupedIncoming.push(image); - existingIds.add(image.id); - existingDedupKeys.add(dedupKey); - acceptedPreviewUrls.add(image.previewUrl); - } - if (dedupedIncoming.length === 0) { - return state; - } - return { - draftsByThreadId: { - ...state.draftsByThreadId, - [threadId]: { - ...existing, - images: [...existing.images, ...dedupedIncoming], + if (dedupedIncoming.length === 0) { + return state; + } + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + images: [...existing.images, ...dedupedIncoming], + }, }, - }, - }; - }); - }, - removeImage: (threadId, imageId) => { - if (threadId.length === 0) { - return; - } - const existing = get().draftsByThreadId[threadId]; - if (!existing) { - return; - } - const removedImage = existing.images.find((image) => image.id === imageId); - if (removedImage) { - revokeObjectPreviewUrl(removedImage.previewUrl); - } - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...current, - images: current.images.filter((image) => image.id !== imageId), - nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId), - persistedAttachments: current.persistedAttachments.filter( - (attachment) => attachment.id !== imageId, - ), - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + }; + }); + }, + removeImage: (threadRef, imageId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - insertTerminalContext: (threadId, prompt, context, index) => { - if (threadId.length === 0) { - return false; - } - let inserted = false; - set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); - const normalizedContext = normalizeTerminalContextForThread(threadId, context); - if (!normalizedContext) { - return state; + const existing = get().draftsByThreadKey[threadKey]; + if (!existing) { + return; } - const dedupKey = terminalContextDedupKey(normalizedContext); - if ( - existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) || - existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey) - ) { - return state; + const removedImage = existing.images.find((image) => image.id === imageId); + if (removedImage) { + revokeObjectPreviewUrl(removedImage.previewUrl); } - inserted = true; - const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index)); - const nextDraft: ComposerThreadDraftState = { - ...existing, - prompt, - terminalContexts: [ - ...existing.terminalContexts.slice(0, boundedIndex), - normalizedContext, - ...existing.terminalContexts.slice(boundedIndex), - ], - }; - return { - draftsByThreadId: { - ...state.draftsByThreadId, - [threadId]: nextDraft, - }, - }; - }); - return inserted; - }, - addTerminalContext: (threadId, context) => { - if (threadId.length === 0) { - return; - } - get().addTerminalContexts(threadId, [context]); - }, - addTerminalContexts: (threadId, contexts) => { - if (threadId.length === 0 || contexts.length === 0) { - return; - } - set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); - const acceptedContexts = normalizeTerminalContextsForThread(threadId, [ - ...existing.terminalContexts, - ...contexts, - ]).slice(existing.terminalContexts.length); - if (acceptedContexts.length === 0) { - return state; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + images: current.images.filter((image) => image.id !== imageId), + nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId), + persistedAttachments: current.persistedAttachments.filter( + (attachment) => attachment.id !== imageId, + ), + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + insertTerminalContext: (threadRef, prompt, context, index) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) { + return false; } - return { - draftsByThreadId: { - ...state.draftsByThreadId, - [threadId]: { - ...existing, - prompt: ensureInlineTerminalContextPlaceholders( - existing.prompt, - existing.terminalContexts.length + acceptedContexts.length, - ), - terminalContexts: [...existing.terminalContexts, ...acceptedContexts], + let inserted = false; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const normalizedContext = normalizeTerminalContextForThread(threadId, context); + if (!normalizedContext) { + return state; + } + const dedupKey = terminalContextDedupKey(normalizedContext); + if ( + existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) || + existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey) + ) { + return state; + } + inserted = true; + const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index)); + const nextDraft: ComposerThreadDraftState = { + ...existing, + prompt, + terminalContexts: [ + ...existing.terminalContexts.slice(0, boundedIndex), + normalizedContext, + ...existing.terminalContexts.slice(boundedIndex), + ], + }; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: nextDraft, }, - }, - }; - }); - }, - removeTerminalContext: (threadId, contextId) => { - if (threadId.length === 0 || contextId.length === 0) { - return; - } - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...current, - terminalContexts: current.terminalContexts.filter( - (context) => context.id !== contextId, - ), - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - clearTerminalContexts: (threadId) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current || current.terminalContexts.length === 0) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...current, - terminalContexts: [], - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + }; + }); + return inserted; + }, + addTerminalContext: (threadRef, context) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - clearPersistedAttachments: (threadId) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current) { - return state; + get().addTerminalContexts( + typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef, + [context], + ); + }, + addTerminalContexts: (threadRef, contexts) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId || contexts.length === 0) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...current, - persistedAttachments: [], - nonPersistedImageIds: [], - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const acceptedContexts = normalizeTerminalContextsForThread(threadId, [ + ...existing.terminalContexts, + ...contexts, + ]).slice(existing.terminalContexts.length); + if (acceptedContexts.length === 0) { + return state; + } + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + prompt: ensureInlineTerminalContextPlaceholders( + existing.prompt, + existing.terminalContexts.length + acceptedContexts.length, + ), + terminalContexts: [...existing.terminalContexts, ...acceptedContexts], + }, + }, + }; + }); + }, + removeTerminalContext: (threadRef, contextId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0 || contextId.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - syncPersistedAttachments: (threadId, attachments) => { - if (threadId.length === 0) { - return; - } - const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id)); - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current) { - return state; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + terminalContexts: current.terminalContexts.filter( + (context) => context.id !== contextId, + ), + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + clearTerminalContexts: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...current, - // Stage attempted attachments so persist middleware can try writing them. - persistedAttachments: attachments, - nonPersistedImageIds: current.nonPersistedImageIds.filter( - (id) => !attachmentIdSet.has(id), - ), - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current || current.terminalContexts.length === 0) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + terminalContexts: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + clearPersistedAttachments: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - Promise.resolve().then(() => { - verifyPersistedAttachments(threadId, attachments, set); - }); - }, - clearComposerContent: (threadId) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current) { - return state; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + persistedAttachments: [], + nonPersistedImageIds: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + syncPersistedAttachments: (threadRef, attachments) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...current, - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id)); + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + // Stage attempted attachments so persist middleware can try writing them. + persistedAttachments: attachments, + nonPersistedImageIds: current.nonPersistedImageIds.filter( + (id) => !attachmentIdSet.has(id), + ), + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + Promise.resolve().then(() => { + verifyPersistedAttachments(threadKey, attachments, set); + }); + }, + clearComposerContent: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - }), + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + prompt: "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + }; + }, { name: COMPOSER_DRAFT_STORAGE_KEY, version: COMPOSER_DRAFT_STORAGE_VERSION, @@ -2140,17 +2705,23 @@ export const useComposerDraftStore = create()( merge: (persistedState, currentState) => { const normalizedPersisted = normalizeCurrentPersistedComposerDraftStoreState(persistedState); - const draftsByThreadId = Object.fromEntries( - Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [ - threadId, + const draftsByThreadKey = Object.fromEntries( + Object.entries(normalizedPersisted.draftsByThreadKey).map(([threadKey, draft]) => [ + threadKey, toHydratedThreadDraft(draft), ]), ); + const draftThreadsByThreadKey = Object.fromEntries( + Object.entries(normalizedPersisted.draftThreadsByThreadKey).map( + ([threadKey, draftThread]) => [threadKey, toHydratedDraftThreadState(draftThread)], + ), + ) as Record; return { ...currentState, - draftsByThreadId, - draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, - projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, + draftsByThreadKey, + draftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey: + normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, }; @@ -2159,19 +2730,24 @@ export const useComposerDraftStore = create()( ), ); -export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { - return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); +export const useComposerDraftStore = composerDraftStore; + +export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState { + return useComposerDraftStore((state) => { + return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT; + }); } export function useEffectiveComposerModelState(input: { - threadId: ThreadId; + threadRef?: ComposerThreadTarget; + draftId?: DraftId; providers: ReadonlyArray; selectedProvider: ProviderKind; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; settings: UnifiedSettings; }): EffectiveComposerModelState { - const draft = useComposerThreadDraft(input.threadId); + const draft = useComposerThreadDraft(input.threadRef ?? input.draftId ?? DraftId.makeUnsafe("")); return useMemo( () => @@ -2195,21 +2771,69 @@ export function useEffectiveComposerModelState(input: { } /** - * Clear a draft thread once the server has materialized the same thread id. + * Mark a draft thread as promoting once the server has materialized the same thread id. * * Use the single-thread helper for live `thread.created` events and the * iterable helper for bootstrap/recovery paths that discover multiple server * threads at once. */ -export function clearPromotedDraftThread(threadId: ThreadId): void { - if (!useComposerDraftStore.getState().getDraftThread(threadId)) { +export function markPromotedDraftThread(threadId: ThreadId): void { + const store = useComposerDraftStore.getState(); + const draftThreadTargets: ComposerThreadTarget[] = []; + for (const [draftId, draftThread] of Object.entries(store.draftThreadsByThreadKey)) { + if (draftThread.threadId === threadId) { + draftThreadTargets.push(DraftId.makeUnsafe(draftId)); + } + } + if (draftThreadTargets.length === 0) { return; } - useComposerDraftStore.getState().clearDraftThread(threadId); + for (const draftThreadTarget of draftThreadTargets) { + store.markDraftThreadPromoting(draftThreadTarget); + } +} + +export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { + const draftStore = useComposerDraftStore.getState(); + for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { + if ( + draftThread.environmentId === threadRef.environmentId && + draftThread.threadId === threadRef.threadId + ) { + draftStore.markDraftThreadPromoting(DraftId.makeUnsafe(draftId), threadRef); + } + } } -export function clearPromotedDraftThreads(serverThreadIds: Iterable): void { +export function markPromotedDraftThreads(serverThreadIds: Iterable): void { for (const threadId of serverThreadIds) { - clearPromotedDraftThread(threadId); + markPromotedDraftThread(threadId); + } +} + +export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable): void { + for (const threadRef of serverThreadRefs) { + markPromotedDraftThreadByRef(threadRef); + } +} + +export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { + const draftStore = useComposerDraftStore.getState(); + for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { + if ( + draftThread.promotedTo && + draftThread.promotedTo.environmentId === threadRef.environmentId && + draftThread.promotedTo.threadId === threadRef.threadId + ) { + draftStore.finalizePromotedDraftThread(DraftId.makeUnsafe(draftId)); + } + } +} + +export function finalizePromotedDraftThreadsByRef( + serverThreadRefs: Iterable, +): void { + for (const threadRef of serverThreadRefs) { + finalizePromotedDraftThreadByRef(threadRef); } } diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index ca43f3e5d8..38c59115a5 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -1,4 +1,4 @@ -import { EDITORS, EditorId, NativeApi } from "@t3tools/contracts"; +import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; import { useMemo } from "react"; @@ -26,7 +26,7 @@ export function resolveAndPersistPreferredEditor( return editor ?? null; } -export async function openInPreferredEditor(api: NativeApi, targetPath: string): Promise { +export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise { const { availableEditors } = await api.server.getConfig(); const editor = resolveAndPersistPreferredEditor(availableEditors); if (!editor) throw new Error("No available editors found."); diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts new file mode 100644 index 0000000000..a565940687 --- /dev/null +++ b/apps/web/src/environmentApi.ts @@ -0,0 +1,67 @@ +import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; + +import { readWsRpcClientEntryForEnvironment, WsRpcClient } from "./wsRpcClient"; + +export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { + return { + terminal: { + open: (input) => rpcClient.terminal.open(input as never), + write: (input) => rpcClient.terminal.write(input as never), + resize: (input) => rpcClient.terminal.resize(input as never), + clear: (input) => rpcClient.terminal.clear(input as never), + restart: (input) => rpcClient.terminal.restart(input as never), + close: (input) => rpcClient.terminal.close(input as never), + onEvent: (callback) => rpcClient.terminal.onEvent(callback), + }, + projects: { + searchEntries: rpcClient.projects.searchEntries, + writeFile: rpcClient.projects.writeFile, + }, + git: { + pull: rpcClient.git.pull, + refreshStatus: rpcClient.git.refreshStatus, + onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), + listBranches: rpcClient.git.listBranches, + createWorktree: rpcClient.git.createWorktree, + removeWorktree: rpcClient.git.removeWorktree, + createBranch: rpcClient.git.createBranch, + checkout: rpcClient.git.checkout, + init: rpcClient.git.init, + resolvePullRequest: rpcClient.git.resolvePullRequest, + preparePullRequestThread: rpcClient.git.preparePullRequestThread, + }, + orchestration: { + getSnapshot: rpcClient.orchestration.getSnapshot, + dispatchCommand: rpcClient.orchestration.dispatchCommand, + getTurnDiff: rpcClient.orchestration.getTurnDiff, + getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, + replayEvents: (fromSequenceExclusive) => + rpcClient.orchestration + .replayEvents({ fromSequenceExclusive }) + .then((events) => [...events]), + onDomainEvent: (callback, options) => + rpcClient.orchestration.onDomainEvent(callback, options), + }, + }; +} + +export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi | undefined { + if (typeof window === "undefined") { + return undefined; + } + + if (!environmentId) { + return undefined; + } + + const entry = readWsRpcClientEntryForEnvironment(environmentId); + return entry ? createEnvironmentApi(entry.client) : undefined; +} + +export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi { + const api = readEnvironmentApi(environmentId); + if (!api) { + throw new Error(`Environment API not found for environment ${environmentId}`); + } + return api; +} diff --git a/apps/web/src/environmentBootstrap.ts b/apps/web/src/environmentBootstrap.ts new file mode 100644 index 0000000000..860c459edf --- /dev/null +++ b/apps/web/src/environmentBootstrap.ts @@ -0,0 +1,65 @@ +import { + createKnownEnvironmentFromWsUrl, + getKnownEnvironmentBaseUrl, + type KnownEnvironment, +} from "@t3tools/client-runtime"; +import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; + +function createKnownEnvironmentFromDesktopBootstrap( + bootstrap: DesktopEnvironmentBootstrap | null | undefined, +): KnownEnvironment | null { + if (!bootstrap?.wsUrl) { + return null; + } + + return createKnownEnvironmentFromWsUrl({ + id: `desktop:${bootstrap.label}`, + label: bootstrap.label, + source: "desktop-managed", + wsUrl: bootstrap.wsUrl, + }); +} + +export function getPrimaryKnownEnvironment(): KnownEnvironment | null { + const desktopEnvironment = createKnownEnvironmentFromDesktopBootstrap( + window.desktopBridge?.getLocalEnvironmentBootstrap(), + ); + if (desktopEnvironment) { + return desktopEnvironment; + } + + const legacyDesktopWsUrl = window.desktopBridge?.getWsUrl(); + if (typeof legacyDesktopWsUrl === "string" && legacyDesktopWsUrl.length > 0) { + return createKnownEnvironmentFromWsUrl({ + id: "desktop-legacy", + label: "Local environment", + source: "desktop-managed", + wsUrl: legacyDesktopWsUrl, + }); + } + + const configuredWsUrl = import.meta.env.VITE_WS_URL; + if (typeof configuredWsUrl === "string" && configuredWsUrl.length > 0) { + return createKnownEnvironmentFromWsUrl({ + id: "configured-primary", + label: "Primary environment", + source: "configured", + wsUrl: configuredWsUrl, + }); + } + + return createKnownEnvironmentFromWsUrl({ + id: "window-origin", + label: "Primary environment", + source: "window-origin", + wsUrl: window.location.origin, + }); +} + +export function resolvePrimaryEnvironmentBootstrapUrl(): string { + const baseUrl = getKnownEnvironmentBaseUrl(getPrimaryKnownEnvironment()); + if (!baseUrl) { + throw new Error("Unable to resolve a known environment bootstrap URL."); + } + return baseUrl; +} diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index f08b2c7a57..ed338e7f38 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,5 +1,6 @@ -import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; +import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import { @@ -7,37 +8,49 @@ import { type DraftThreadState, useComposerDraftStore, } from "../composerDraftStore"; -import { newThreadId } from "../lib/utils"; +import { newDraftId, newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; -import { useStore } from "../store"; -import { createThreadSelector } from "../storeSelectors"; +import { deriveLogicalProjectKey } from "../logicalProject"; +import { selectProjectsAcrossEnvironments, useStore } from "../store"; +import { createThreadSelectorByRef } from "../storeSelectors"; +import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; export function useHandleNewThread() { - const projectIds = useStore(useShallow((store) => store.projectIds)); + const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); const projectOrder = useUiStateStore((store) => store.projectOrder); - const navigate = useNavigate(); - const routeThreadId = useParams({ + const router = useRouter(); + const routeTarget = useParams({ strict: false, - select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + select: (params) => resolveThreadRouteTarget(params), }); + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const activeThread = useStore( - useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]), + useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), ); - const activeDraftThread = useComposerDraftStore((store) => - routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, + const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); + const activeDraftThread = useComposerDraftStore(() => + routeTarget + ? routeTarget.kind === "server" + ? getDraftThread(routeTarget.threadRef) + : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) + : null, ); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ - items: projectIds, + items: projects, preferredIds: projectOrder, - getId: (projectId) => projectId, + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), }); - }, [projectIds, projectOrder]); + }, [projectOrder, projects]); + const getCurrentRouteTarget = useCallback(() => { + const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; + return resolveThreadRouteTarget(currentRouteParams); + }, [router]); const handleNewThread = useCallback( ( - projectId: ProjectId, + projectRef: ScopedProjectRef, options?: { branch?: string | null; worktreePath?: string | null; @@ -45,84 +58,110 @@ export function useHandleNewThread() { }, ): Promise => { const { - clearProjectDraftThreadId, + getDraftSessionByLogicalProjectKey, + getDraftSession, getDraftThread, - getDraftThreadByProjectId, applyStickyState, setDraftThreadContext, - setProjectDraftThreadId, + setLogicalProjectDraftThreadId, } = useComposerDraftStore.getState(); + const currentRouteTarget = getCurrentRouteTarget(); + const project = projects.find( + (candidate) => + candidate.id === projectRef.projectId && + candidate.environmentId === projectRef.environmentId, + ); + const logicalProjectKey = project + ? deriveLogicalProjectKey(project) + : scopedProjectKey(projectRef); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; - const storedDraftThread = getDraftThreadByProjectId(projectId); - const latestActiveDraftThread: DraftThreadState | null = routeThreadId - ? getDraftThread(routeThreadId) + const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); + const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget + ? currentRouteTarget.kind === "server" + ? getDraftThread(currentRouteTarget.threadRef) + : getDraftSession(currentRouteTarget.draftId) : null; if (storedDraftThread) { return (async () => { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(storedDraftThread.threadId, { + setDraftThreadContext(storedDraftThread.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } - setProjectDraftThreadId(projectId, storedDraftThread.threadId); - if (routeThreadId === storedDraftThread.threadId) { + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, storedDraftThread.draftId, { + threadId: storedDraftThread.threadId, + }); + if ( + currentRouteTarget?.kind === "draft" && + currentRouteTarget.draftId === storedDraftThread.draftId + ) { return; } - await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, + await router.navigate({ + to: "/draft/$draftId", + params: { draftId: storedDraftThread.draftId }, }); })(); } - clearProjectDraftThreadId(projectId); - if ( latestActiveDraftThread && - routeThreadId && - latestActiveDraftThread.projectId === projectId + currentRouteTarget?.kind === "draft" && + latestActiveDraftThread.logicalProjectKey === logicalProjectKey ) { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(routeThreadId, { + setDraftThreadContext(currentRouteTarget.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } - setProjectDraftThreadId(projectId, routeThreadId); + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { + threadId: latestActiveDraftThread.threadId, + createdAt: latestActiveDraftThread.createdAt, + runtimeMode: latestActiveDraftThread.runtimeMode, + interactionMode: latestActiveDraftThread.interactionMode, + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + }); return Promise.resolve(); } + const draftId = newDraftId(); const threadId = newThreadId(); const createdAt = new Date().toISOString(); return (async () => { - setProjectDraftThreadId(projectId, threadId, { + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { + threadId, createdAt, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); - applyStickyState(threadId); + applyStickyState(draftId); - await navigate({ - to: "/$threadId", - params: { threadId }, + await router.navigate({ + to: "/draft/$draftId", + params: { draftId }, }); })(); }, - [navigate, routeThreadId], + [getCurrentRouteTarget, router, projects], ); return { activeDraftThread, activeThread, - defaultProjectId: orderedProjects[0] ?? null, + defaultProjectRef: orderedProjects[0] + ? scopeProjectRef(orderedProjects[0].environmentId, orderedProjects[0].id) + : null, handleNewThread, - routeThreadId, + routeThreadRef, }; } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index f6b43f9a77..a953bc4656 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -26,7 +26,7 @@ import { TimestampFormat, UnifiedSettings, } from "@t3tools/contracts/settings"; -import { ensureNativeApi } from "~/nativeApi"; +import { ensureLocalApi } from "~/localApi"; import { useLocalStorage } from "./useLocalStorage"; import { normalizeCustomModelSlugs } from "~/modelSelection"; import { Predicate, Schema, Struct } from "effect"; @@ -67,9 +67,7 @@ function splitPatch(patch: Partial): { * only re-render when the slice they care about changes. */ -export function useSettings( - selector?: (s: UnifiedSettings) => T, -): T { +export function useSettings(selector?: (s: UnifiedSettings) => T): T { const serverSettings = useServerSettings(); const [clientSettings] = useLocalStorage( CLIENT_SETTINGS_STORAGE_KEY, @@ -111,7 +109,7 @@ export function useUpdateSettings() { applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); } // Fire-and-forget RPC — push will reconcile on success - void ensureNativeApi().server.updateSettings(serverPatch); + void ensureLocalApi().server.updateSettings(serverPatch); } if (Object.keys(clientPatch).length > 0) { @@ -239,7 +237,7 @@ export function migrateLocalSettingsToServer(): void { // Migrate server-relevant keys via RPC const serverPatch = buildLegacyServerSettingsMigrationPatch(old); if (Object.keys(serverPatch).length > 0) { - const api = ensureNativeApi(); + const api = ensureLocalApi(); void api.server.updateSettings(serverPatch); } diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index bc13b872cd..7087002bcd 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,42 +1,63 @@ -import { ThreadId } from "@t3tools/contracts"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "@tanstack/react-router"; import { useCallback } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "./useHandleNewThread"; -import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; +import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; +import { invalidateGitQueries } from "../lib/gitReactQuery"; import { newCommandId } from "../lib/utils"; -import { readNativeApi } from "../nativeApi"; -import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store"; +import { readLocalApi } from "../localApi"; +import { + selectProjectByRef, + selectThreadByRef, + selectThreadsForEnvironment, + useStore, +} from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; export function useThreadActions() { - const appSettings = useSettings(); + const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); + const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, ); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); - const routeThreadId = useParams({ - strict: false, - select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), - }); - const navigate = useNavigate(); + const router = useRouter(); const { handleNewThread } = useHandleNewThread(); const queryClient = useQueryClient(); - const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); + + const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { + const state = useStore.getState(); + const thread = selectThreadByRef(state, target); + if (!thread) { + return null; + } + return { + thread, + threadRef: target, + }; + }, []); + const getCurrentRouteThreadRef = useCallback(() => { + const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; + return resolveThreadRouteRef(currentRouteParams); + }, [router]); const archiveThread = useCallback( - async (threadId: ThreadId) => { - const api = readNativeApi(); + async (target: ScopedThreadRef) => { + const api = readEnvironmentApi(target.environmentId); if (!api) return; - const thread = selectThreadById(threadId)(useStore.getState()); - if (!thread) return; + const resolved = resolveThreadTarget(target); + if (!resolved) return; + const { thread, threadRef } = resolved; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { throw new Error("Cannot archive a running thread."); } @@ -44,48 +65,69 @@ export function useThreadActions() { await api.orchestration.dispatchCommand({ type: "thread.archive", commandId: newCommandId(), - threadId, + threadId: threadRef.threadId, }); + const currentRouteThreadRef = getCurrentRouteThreadRef(); - if (routeThreadId === threadId) { - await handleNewThread(thread.projectId); + if ( + currentRouteThreadRef?.threadId === threadRef.threadId && + currentRouteThreadRef.environmentId === threadRef.environmentId + ) { + await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId)); } }, - [handleNewThread, routeThreadId], + [getCurrentRouteThreadRef, handleNewThread, resolveThreadTarget], ); - const unarchiveThread = useCallback(async (threadId: ThreadId) => { - const api = readNativeApi(); + const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { + const api = readEnvironmentApi(target.environmentId); if (!api) return; await api.orchestration.dispatchCommand({ type: "thread.unarchive", commandId: newCommandId(), - threadId, + threadId: target.threadId, }); }, []); const deleteThread = useCallback( - async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet } = {}) => { - const api = readNativeApi(); + async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { + const api = readEnvironmentApi(target.environmentId); if (!api) return; + const resolved = resolveThreadTarget(target); + if (!resolved) return; + const { thread, threadRef } = resolved; const state = useStore.getState(); - const threads = selectThreads(state); - const thread = selectThreadById(threadId)(state); - if (!thread) return; - const threadProject = selectProjectById(thread.projectId)(state); - const deletedIds = opts.deletedThreadIds; + const threads = selectThreadsForEnvironment(state, threadRef.environmentId); + const threadProject = selectProjectByRef(state, { + environmentId: threadRef.environmentId, + projectId: thread.projectId, + }); + const deletedIds = + opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0 + ? new Set( + [...opts.deletedThreadKeys].flatMap((threadKey) => { + const ref = parseScopedThreadKey(threadKey); + return ref && ref.environmentId === threadRef.environmentId ? [ref.threadId] : []; + }), + ) + : undefined; const survivingThreads = deletedIds && deletedIds.size > 0 - ? threads.filter((entry) => entry.id === threadId || !deletedIds.has(entry.id)) + ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) : threads; - const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId); + const orphanedWorktreePath = getOrphanedWorktreePathForThread( + survivingThreads, + threadRef.threadId, + ); const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const localApi = readLocalApi(); const shouldDeleteWorktree = canDeleteWorktree && - (await api.dialogs.confirm( + localApi && + (await localApi.dialogs.confirm( [ "This thread is the only one linked to this worktree:", displayWorktreePath ?? orphanedWorktreePath, @@ -99,44 +141,60 @@ export function useThreadActions() { .dispatchCommand({ type: "thread.session.stop", commandId: newCommandId(), - threadId, + threadId: threadRef.threadId, createdAt: new Date().toISOString(), }) .catch(() => undefined); } try { - await api.terminal.close({ threadId, deleteHistory: true }); + await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true }); } catch { // Terminal may already be closed. } - const deletedThreadIds = opts.deletedThreadIds ?? new Set(); - const shouldNavigateToFallback = routeThreadId === threadId; + const deletedThreadIds = deletedIds ?? new Set(); + const currentRouteThreadRef = getCurrentRouteThreadRef(); + const shouldNavigateToFallback = + currentRouteThreadRef?.threadId === threadRef.threadId && + currentRouteThreadRef.environmentId === threadRef.environmentId; const fallbackThreadId = getFallbackThreadIdAfterDelete({ threads, - deletedThreadId: threadId, + deletedThreadId: threadRef.threadId, deletedThreadIds, - sortOrder: appSettings.sidebarThreadSortOrder, + sortOrder: sidebarThreadSortOrder, }); await api.orchestration.dispatchCommand({ type: "thread.delete", commandId: newCommandId(), - threadId, + threadId: threadRef.threadId, }); - clearComposerDraftForThread(threadId); - clearProjectDraftThreadById(thread.projectId, thread.id); - clearTerminalState(threadId); + clearComposerDraftForThread(threadRef); + clearProjectDraftThreadById( + scopeProjectRef(threadRef.environmentId, thread.projectId), + threadRef, + ); + clearTerminalState(threadRef); if (shouldNavigateToFallback) { if (fallbackThreadId) { - await navigate({ - to: "/$threadId", - params: { threadId: fallbackThreadId }, - replace: true, - }); + const fallbackThread = selectThreadByRef( + useStore.getState(), + scopeThreadRef(threadRef.environmentId, fallbackThreadId), + ); + if (fallbackThread) { + await router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), + ), + replace: true, + }); + } else { + await router.navigate({ to: "/", replace: true }); + } } else { - await navigate({ to: "/", replace: true }); + await router.navigate({ to: "/", replace: true }); } } @@ -145,15 +203,18 @@ export function useThreadActions() { } try { - await removeWorktreeMutation.mutateAsync({ + await ensureEnvironmentApi(threadRef.environmentId).git.removeWorktree({ cwd: threadProject.cwd, path: orphanedWorktreePath, force: true, }); + await invalidateGitQueries(queryClient, { + environmentId: threadRef.environmentId, + }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error removing worktree."; console.error("Failed to remove orphaned worktree after thread deletion", { - threadId, + threadId: threadRef.threadId, projectCwd: threadProject.cwd, worktreePath: orphanedWorktreePath, error, @@ -169,22 +230,25 @@ export function useThreadActions() { clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalState, - appSettings.sidebarThreadSortOrder, - navigate, - removeWorktreeMutation, - routeThreadId, + getCurrentRouteThreadRef, + router, + queryClient, + resolveThreadTarget, + sidebarThreadSortOrder, ], ); const confirmAndDeleteThread = useCallback( - async (threadId: ThreadId) => { - const api = readNativeApi(); + async (target: ScopedThreadRef) => { + const api = readEnvironmentApi(target.environmentId); if (!api) return; - const thread = selectThreadById(threadId)(useStore.getState()); - if (!thread) return; + const localApi = readLocalApi(); + const resolved = resolveThreadTarget(target); + if (!resolved) return; + const { thread } = resolved; - if (appSettings.confirmThreadDelete) { - const confirmed = await api.dialogs.confirm( + if (confirmThreadDelete && localApi) { + const confirmed = await localApi.dialogs.confirm( [ `Delete thread "${thread.title}"?`, "This permanently clears conversation history for this thread.", @@ -195,9 +259,9 @@ export function useThreadActions() { } } - await deleteThread(threadId); + await deleteThread(target); }, - [appSettings.confirmThreadDelete, deleteThread], + [confirmThreadDelete, deleteThread, resolveThreadTarget], ); return { diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index 254b93eb6d..71788b5eac 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -1,16 +1,17 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; -vi.mock("../nativeApi", () => ({ - ensureNativeApi: vi.fn(), +vi.mock("../environmentApi", () => ({ + ensureEnvironmentApi: vi.fn(), })); vi.mock("../wsRpcClient", () => ({ getWsRpcClient: vi.fn(), + getWsRpcClientForEnvironment: vi.fn(), })); import type { InfiniteData } from "@tanstack/react-query"; -import type { GitListBranchesResult } from "@t3tools/contracts"; +import { EnvironmentId, type GitListBranchesResult } from "@t3tools/contracts"; import { gitBranchSearchInfiniteQueryOptions, @@ -33,21 +34,25 @@ const BRANCH_SEARCH_RESULT: InfiniteData = { pages: [BRANCH_QUERY_RESULT], pageParams: [0], }; +const ENVIRONMENT_A = EnvironmentId.makeUnsafe("environment-a"); +const ENVIRONMENT_B = EnvironmentId.makeUnsafe("environment-b"); describe("gitMutationKeys", () => { it("scopes stacked action keys by cwd", () => { - expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( - gitMutationKeys.runStackedAction("/repo/b"), + expect(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")).not.toEqual( + gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/b"), ); }); it("scopes pull keys by cwd", () => { - expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); + expect(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")).not.toEqual( + gitMutationKeys.pull(ENVIRONMENT_A, "/repo/b"), + ); }); it("scopes pull request thread preparation keys by cwd", () => { - expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual( - gitMutationKeys.preparePullRequestThread("/repo/b"), + expect(gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a")).not.toEqual( + gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/b"), ); }); }); @@ -57,23 +62,31 @@ describe("git mutation options", () => { it("attaches cwd-scoped mutation key for runStackedAction", () => { const options = gitRunStackedActionMutationOptions({ + environmentId: ENVIRONMENT_A, cwd: "/repo/a", queryClient, }); - expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); + expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")); }); it("attaches cwd-scoped mutation key for pull", () => { - const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); - expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); + const options = gitPullMutationOptions({ + environmentId: ENVIRONMENT_A, + cwd: "/repo/a", + queryClient, + }); + expect(options.mutationKey).toEqual(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")); }); it("attaches cwd-scoped mutation key for preparePullRequestThread", () => { const options = gitPreparePullRequestThreadMutationOptions({ + environmentId: ENVIRONMENT_A, cwd: "/repo/a", queryClient, }); - expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a")); + expect(options.mutationKey).toEqual( + gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a"), + ); }); }); @@ -83,6 +96,7 @@ describe("invalidateGitQueries", () => { queryClient.setQueryData( gitBranchSearchInfiniteQueryOptions({ + environmentId: ENVIRONMENT_A, cwd: "/repo/a", query: "feature", }).queryKey, @@ -90,17 +104,19 @@ describe("invalidateGitQueries", () => { ); queryClient.setQueryData( gitBranchSearchInfiniteQueryOptions({ + environmentId: ENVIRONMENT_B, cwd: "/repo/b", query: "feature", }).queryKey, BRANCH_SEARCH_RESULT, ); - await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); + await invalidateGitQueries(queryClient, { environmentId: ENVIRONMENT_A, cwd: "/repo/a" }); expect( queryClient.getQueryState( gitBranchSearchInfiniteQueryOptions({ + environmentId: ENVIRONMENT_A, cwd: "/repo/a", query: "feature", }).queryKey, @@ -109,6 +125,7 @@ describe("invalidateGitQueries", () => { expect( queryClient.getQueryState( gitBranchSearchInfiniteQueryOptions({ + environmentId: ENVIRONMENT_B, cwd: "/repo/b", query: "feature", }).queryKey, diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index a2611ebe25..5651a19b01 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,5 @@ import { + type EnvironmentId, type GitActionProgressEvent, type GitStackedAction, type ThreadId, @@ -9,8 +10,8 @@ import { queryOptions, type QueryClient, } from "@tanstack/react-query"; -import { ensureNativeApi } from "../nativeApi"; -import { getWsRpcClient } from "../wsRpcClient"; +import { ensureEnvironmentApi } from "../environmentApi"; +import { getWsRpcClientForEnvironment } from "../wsRpcClient"; const GIT_BRANCHES_STALE_TIME_MS = 15_000; const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; @@ -18,38 +19,52 @@ const GIT_BRANCHES_PAGE_SIZE = 100; export const gitQueryKeys = { all: ["git"] as const, - branches: (cwd: string | null) => ["git", "branches", cwd] as const, - branchSearch: (cwd: string | null, query: string) => - ["git", "branches", cwd, "search", query] as const, + branches: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "branches", environmentId ?? null, cwd] as const, + branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) => + ["git", "branches", environmentId ?? null, cwd, "search", query] as const, }; export const gitMutationKeys = { - init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const, - checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const, - runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, - pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, - preparePullRequestThread: (cwd: string | null) => - ["git", "mutation", "prepare-pull-request-thread", cwd] as const, + init: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "init", environmentId ?? null, cwd] as const, + checkout: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "checkout", environmentId ?? null, cwd] as const, + runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const, + pull: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "pull", environmentId ?? null, cwd] as const, + preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "prepare-pull-request-thread", environmentId ?? null, cwd] as const, }; -export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) { +export function invalidateGitQueries( + queryClient: QueryClient, + input?: { environmentId?: EnvironmentId | null; cwd?: string | null }, +) { + const environmentId = input?.environmentId ?? null; const cwd = input?.cwd ?? null; if (cwd !== null) { - return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); } return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); } -function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null) { +function invalidateGitBranchQueries( + queryClient: QueryClient, + environmentId: EnvironmentId | null, + cwd: string | null, +) { if (cwd === null) { return Promise.resolve(); } - return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); } export function gitBranchSearchInfiniteQueryOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; query: string; enabled?: boolean; @@ -57,11 +72,12 @@ export function gitBranchSearchInfiniteQueryOptions(input: { const normalizedQuery = input.query.trim(); return infiniteQueryOptions({ - queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery), + queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery), initialPageParam: 0, queryFn: async ({ pageParam }) => { - const api = ensureNativeApi(); if (!input.cwd) throw new Error("Git branches are unavailable."); + if (!input.environmentId) throw new Error("Git branches are unavailable."); + const api = ensureEnvironmentApi(input.environmentId); return api.git.listBranches({ cwd: input.cwd, ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), @@ -79,62 +95,75 @@ export function gitBranchSearchInfiniteQueryOptions(input: { } export function gitResolvePullRequestQueryOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; reference: string | null; }) { return queryOptions({ - queryKey: ["git", "pull-request", input.cwd, input.reference] as const, + queryKey: [ + "git", + "pull-request", + input.environmentId ?? null, + input.cwd, + input.reference, + ] as const, queryFn: async () => { - const api = ensureNativeApi(); - if (!input.cwd || !input.reference) { + if (!input.cwd || !input.reference || !input.environmentId) { throw new Error("Pull request lookup is unavailable."); } + const api = ensureEnvironmentApi(input.environmentId); return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference }); }, - enabled: input.cwd !== null && input.reference !== null, + enabled: input.environmentId !== null && input.cwd !== null && input.reference !== null, staleTime: 30_000, refetchOnWindowFocus: false, refetchOnReconnect: false, }); } -export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { +export function gitInitMutationOptions(input: { + environmentId: EnvironmentId | null; + cwd: string | null; + queryClient: QueryClient; +}) { return mutationOptions({ - mutationKey: gitMutationKeys.init(input.cwd), + mutationKey: gitMutationKeys.init(input.environmentId, input.cwd), mutationFn: async () => { - const api = ensureNativeApi(); - if (!input.cwd) throw new Error("Git init is unavailable."); + if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable."); + const api = ensureEnvironmentApi(input.environmentId); return api.git.init({ cwd: input.cwd }); }, onSettled: async () => { - await invalidateGitBranchQueries(input.queryClient, input.cwd); + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); }, }); } export function gitCheckoutMutationOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; queryClient: QueryClient; }) { return mutationOptions({ - mutationKey: gitMutationKeys.checkout(input.cwd), + mutationKey: gitMutationKeys.checkout(input.environmentId, input.cwd), mutationFn: async (branch: string) => { - const api = ensureNativeApi(); - if (!input.cwd) throw new Error("Git checkout is unavailable."); + if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable."); + const api = ensureEnvironmentApi(input.environmentId); return api.git.checkout({ cwd: input.cwd, branch }); }, onSettled: async () => { - await invalidateGitBranchQueries(input.queryClient, input.cwd); + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); }, }); } export function gitRunStackedActionMutationOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; queryClient: QueryClient; }) { return mutationOptions({ - mutationKey: gitMutationKeys.runStackedAction(input.cwd), + mutationKey: gitMutationKeys.runStackedAction(input.environmentId, input.cwd), mutationFn: async ({ actionId, action, @@ -150,8 +179,8 @@ export function gitRunStackedActionMutationOptions(input: { filePaths?: string[]; onProgress?: (event: GitActionProgressEvent) => void; }) => { - if (!input.cwd) throw new Error("Git action is unavailable."); - return getWsRpcClient().git.runStackedAction( + if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable."); + return getWsRpcClientForEnvironment(input.environmentId).git.runStackedAction( { action, actionId, @@ -164,62 +193,85 @@ export function gitRunStackedActionMutationOptions(input: { ); }, onSuccess: async () => { - await invalidateGitBranchQueries(input.queryClient, input.cwd); + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); }, }); } -export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { +export function gitPullMutationOptions(input: { + environmentId: EnvironmentId | null; + cwd: string | null; + queryClient: QueryClient; +}) { return mutationOptions({ - mutationKey: gitMutationKeys.pull(input.cwd), + mutationKey: gitMutationKeys.pull(input.environmentId, input.cwd), mutationFn: async () => { - const api = ensureNativeApi(); - if (!input.cwd) throw new Error("Git pull is unavailable."); + if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable."); + const api = ensureEnvironmentApi(input.environmentId); return api.git.pull({ cwd: input.cwd }); }, onSuccess: async () => { - await invalidateGitBranchQueries(input.queryClient, input.cwd); + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); }, }); } -export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { +export function gitCreateWorktreeMutationOptions(input: { + environmentId: EnvironmentId | null; + queryClient: QueryClient; +}) { return mutationOptions({ - mutationKey: ["git", "mutation", "create-worktree"] as const, + mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const, mutationFn: ( - args: Parameters["git"]["createWorktree"]>[0], - ) => ensureNativeApi().git.createWorktree(args), + args: Parameters["git"]["createWorktree"]>[0], + ) => { + if (!input.environmentId) { + throw new Error("Worktree creation is unavailable."); + } + return ensureEnvironmentApi(input.environmentId).git.createWorktree(args); + }, onSuccess: async () => { - await invalidateGitQueries(input.queryClient); + await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); }, }); } -export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { +export function gitRemoveWorktreeMutationOptions(input: { + environmentId: EnvironmentId | null; + queryClient: QueryClient; +}) { return mutationOptions({ - mutationKey: ["git", "mutation", "remove-worktree"] as const, + mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const, mutationFn: ( - args: Parameters["git"]["removeWorktree"]>[0], - ) => ensureNativeApi().git.removeWorktree(args), + args: Parameters["git"]["removeWorktree"]>[0], + ) => { + if (!input.environmentId) { + throw new Error("Worktree removal is unavailable."); + } + return ensureEnvironmentApi(input.environmentId).git.removeWorktree(args); + }, onSuccess: async () => { - await invalidateGitQueries(input.queryClient); + await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); }, }); } export function gitPreparePullRequestThreadMutationOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; queryClient: QueryClient; }) { return mutationOptions({ - mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), + mutationKey: gitMutationKeys.preparePullRequestThread(input.environmentId, input.cwd), mutationFn: async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId; }) => { - const api = ensureNativeApi(); - if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); + if (!input.cwd || !input.environmentId) { + throw new Error("Pull request thread preparation is unavailable."); + } + const api = ensureEnvironmentApi(input.environmentId); return api.git.preparePullRequestThread({ cwd: input.cwd, reference: args.reference, @@ -228,7 +280,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: { }); }, onSuccess: async () => { - await invalidateGitBranchQueries(input.queryClient, input.cwd); + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); }, }); } diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 757130db9b..5ffed921bb 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -1,4 +1,4 @@ -import type { GitStatusResult } from "@t3tools/contracts"; +import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -16,6 +16,10 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even } const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); +const ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); +const OTHER_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-remote"); +const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; +const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; const BASE_STATUS: GitStatusResult = { isRepo: true, @@ -55,7 +59,7 @@ afterEach(() => { describe("gitStatusState", () => { it("starts fresh cwd state in a pending state", () => { - expect(getGitStatusSnapshot("/fresh")).toEqual({ + expect(getGitStatusSnapshot(FRESH_TARGET)).toEqual({ data: null, error: null, cause: null, @@ -64,11 +68,11 @@ describe("gitStatusState", () => { }); it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => { - const releaseA = watchGitStatus("/repo", gitClient); - const releaseB = watchGitStatus("/repo", gitClient); + const releaseA = watchGitStatus(TARGET, gitClient); + const releaseB = watchGitStatus(TARGET, gitClient); expect(gitClient.onStatus).toHaveBeenCalledOnce(); - expect(getGitStatusSnapshot("/repo")).toEqual({ + expect(getGitStatusSnapshot(TARGET)).toEqual({ data: null, error: null, cause: null, @@ -77,7 +81,7 @@ describe("gitStatusState", () => { emitGitStatus(BASE_STATUS); - expect(getGitStatusSnapshot("/repo")).toEqual({ + expect(getGitStatusSnapshot(TARGET)).toEqual({ data: BASE_STATUS, error: null, cause: null, @@ -92,15 +96,15 @@ describe("gitStatusState", () => { }); it("refreshes git status through the unary RPC without restarting the stream", async () => { - const release = watchGitStatus("/repo", gitClient); + const release = watchGitStatus(TARGET, gitClient); emitGitStatus(BASE_STATUS); - const refreshed = await refreshGitStatus("/repo", gitClient); + const refreshed = await refreshGitStatus(TARGET, gitClient); expect(gitClient.onStatus).toHaveBeenCalledOnce(); expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" }); - expect(getGitStatusSnapshot("/repo")).toEqual({ + expect(getGitStatusSnapshot(TARGET)).toEqual({ data: BASE_STATUS, error: null, cause: null, @@ -109,4 +113,38 @@ describe("gitStatusState", () => { release(); }); + + it("keeps git status subscriptions isolated by environment when cwds match", () => { + const localListeners = new Set<(event: GitStatusResult) => void>(); + const remoteListeners = new Set<(event: GitStatusResult) => void>(); + const localClient = { + refreshStatus: vi.fn(), + onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => + registerListener(localListeners, listener), + ), + }; + const remoteClient = { + refreshStatus: vi.fn(), + onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => + registerListener(remoteListeners, listener), + ), + }; + const remoteTarget = { environmentId: OTHER_ENVIRONMENT_ID, cwd: "/repo" } as const; + + const releaseLocal = watchGitStatus(TARGET, localClient); + const releaseRemote = watchGitStatus(remoteTarget, remoteClient); + + for (const listener of localListeners) { + listener(BASE_STATUS); + } + for (const listener of remoteListeners) { + listener({ ...BASE_STATUS, branch: "remote-branch" }); + } + + expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status"); + expect(getGitStatusSnapshot(remoteTarget).data?.branch).toBe("remote-branch"); + + releaseLocal(); + releaseRemote(); + }); }); diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index 1c1cf00864..cabd2d9b94 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -1,11 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; -import { type GitManagerServiceError, type GitStatusResult } from "@t3tools/contracts"; +import { + type EnvironmentId, + type GitManagerServiceError, + type GitStatusResult, +} from "@t3tools/contracts"; import { Cause } from "effect"; import { Atom } from "effect/unstable/reactivity"; import { useEffect } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient"; +import { getWsRpcClient, getWsRpcClientForEnvironment, type WsRpcClient } from "../wsRpcClient"; export type GitStatusStreamError = GitManagerServiceError; @@ -23,6 +27,11 @@ interface WatchedGitStatus { unsubscribe: () => void; } +export interface GitStatusTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; +} + const EMPTY_GIT_STATUS_STATE = Object.freeze({ data: null, error: null, @@ -40,79 +49,91 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( const NOOP: () => void = () => undefined; const watchedGitStatuses = new Map(); -const knownGitStatusCwds = new Set(); +const knownGitStatusKeys = new Set(); const gitStatusRefreshInFlight = new Map>(); -const gitStatusLastRefreshAtByCwd = new Map(); +const gitStatusLastRefreshAtByKey = new Map(); const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; -let sharedGitStatusClient: GitStatusClient | null = null; - -const gitStatusStateAtom = Atom.family((cwd: string) => { - knownGitStatusCwds.add(cwd); +const gitStatusStateAtom = Atom.family((key: string) => { + knownGitStatusKeys.add(key); return Atom.make(INITIAL_GIT_STATUS_STATE).pipe( Atom.keepAlive, - Atom.withLabel(`git-status:${cwd}`), + Atom.withLabel(`git-status:${key}`), ); }); -export function getGitStatusSnapshot(cwd: string | null): GitStatusState { - if (cwd === null) { +function getGitStatusTargetKey(target: GitStatusTarget): string | null { + if (target.cwd === null) { + return null; + } + + return `${target.environmentId ?? "__default__"}:${target.cwd}`; +} + +function resolveGitStatusClient(target: GitStatusTarget): GitStatusClient { + if (target.environmentId) { + return getWsRpcClientForEnvironment(target.environmentId).git; + } + return getWsRpcClient().git; +} + +export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState { + const targetKey = getGitStatusTargetKey(target); + if (targetKey === null) { return EMPTY_GIT_STATUS_STATE; } - return appAtomRegistry.get(gitStatusStateAtom(cwd)); + return appAtomRegistry.get(gitStatusStateAtom(targetKey)); } export function watchGitStatus( - cwd: string | null, - client: GitStatusClient = getWsRpcClient().git, + target: GitStatusTarget, + client: GitStatusClient = resolveGitStatusClient(target), ): () => void { - if (cwd === null) { + const targetKey = getGitStatusTargetKey(target); + if (targetKey === null) { return NOOP; } - ensureGitStatusClient(client); - - const watched = watchedGitStatuses.get(cwd); + const watched = watchedGitStatuses.get(targetKey); if (watched) { watched.refCount += 1; - return () => unwatchGitStatus(cwd); + return () => unwatchGitStatus(targetKey); } - watchedGitStatuses.set(cwd, { + watchedGitStatuses.set(targetKey, { refCount: 1, - unsubscribe: subscribeToGitStatus(cwd), + unsubscribe: subscribeToGitStatus(targetKey, target.cwd!, client), }); - return () => unwatchGitStatus(cwd); + return () => unwatchGitStatus(targetKey); } export function refreshGitStatus( - cwd: string | null, - client: GitStatusClient = getWsRpcClient().git, + target: GitStatusTarget, + client: GitStatusClient = resolveGitStatusClient(target), ): Promise { - if (cwd === null) { + const targetKey = getGitStatusTargetKey(target); + if (targetKey === null || target.cwd === null) { return Promise.resolve(null); } - ensureGitStatusClient(client); - - const currentInFlight = gitStatusRefreshInFlight.get(cwd); + const currentInFlight = gitStatusRefreshInFlight.get(targetKey); if (currentInFlight) { return currentInFlight; } - const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0; + const lastRequestedAt = gitStatusLastRefreshAtByKey.get(targetKey) ?? 0; if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) { - return Promise.resolve(getGitStatusSnapshot(cwd).data); + return Promise.resolve(getGitStatusSnapshot(target).data); } - gitStatusLastRefreshAtByCwd.set(cwd, Date.now()); - const refreshPromise = client.refreshStatus({ cwd }).finally(() => { - gitStatusRefreshInFlight.delete(cwd); + gitStatusLastRefreshAtByKey.set(targetKey, Date.now()); + const refreshPromise = client.refreshStatus({ cwd: target.cwd }).finally(() => { + gitStatusRefreshInFlight.delete(targetKey); }); - gitStatusRefreshInFlight.set(cwd, refreshPromise); + gitStatusRefreshInFlight.set(targetKey, refreshPromise); return refreshPromise; } @@ -122,43 +143,29 @@ export function resetGitStatusStateForTests(): void { } watchedGitStatuses.clear(); gitStatusRefreshInFlight.clear(); - gitStatusLastRefreshAtByCwd.clear(); - sharedGitStatusClient = null; + gitStatusLastRefreshAtByKey.clear(); - for (const cwd of knownGitStatusCwds) { - appAtomRegistry.set(gitStatusStateAtom(cwd), INITIAL_GIT_STATUS_STATE); + for (const key of knownGitStatusKeys) { + appAtomRegistry.set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE); } - knownGitStatusCwds.clear(); -} - -export function useGitStatus(cwd: string | null): GitStatusState { - useEffect(() => watchGitStatus(cwd), [cwd]); - - const state = useAtomValue(cwd !== null ? gitStatusStateAtom(cwd) : EMPTY_GIT_STATUS_ATOM); - return cwd === null ? EMPTY_GIT_STATUS_STATE : state; + knownGitStatusKeys.clear(); } -function ensureGitStatusClient(client: GitStatusClient): void { - if (sharedGitStatusClient === client) { - return; - } - - if (sharedGitStatusClient !== null) { - resetLiveGitStatusSubscriptions(); - } - - sharedGitStatusClient = client; -} +export function useGitStatus(target: GitStatusTarget): GitStatusState { + const targetKey = getGitStatusTargetKey(target); + useEffect( + () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }), + [target.environmentId, target.cwd], + ); -function resetLiveGitStatusSubscriptions(): void { - for (const watched of watchedGitStatuses.values()) { - watched.unsubscribe(); - } - watchedGitStatuses.clear(); + const state = useAtomValue( + targetKey !== null ? gitStatusStateAtom(targetKey) : EMPTY_GIT_STATUS_ATOM, + ); + return targetKey === null ? EMPTY_GIT_STATUS_STATE : state; } -function unwatchGitStatus(cwd: string): void { - const watched = watchedGitStatuses.get(cwd); +function unwatchGitStatus(targetKey: string): void { + const watched = watchedGitStatuses.get(targetKey); if (!watched) { return; } @@ -169,20 +176,15 @@ function unwatchGitStatus(cwd: string): void { } watched.unsubscribe(); - watchedGitStatuses.delete(cwd); + watchedGitStatuses.delete(targetKey); } -function subscribeToGitStatus(cwd: string): () => void { - const client = sharedGitStatusClient; - if (!client) { - return NOOP; - } - - markGitStatusPending(cwd); +function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void { + markGitStatusPending(targetKey); return client.onStatus( { cwd }, (status) => { - appAtomRegistry.set(gitStatusStateAtom(cwd), { + appAtomRegistry.set(gitStatusStateAtom(targetKey), { data: status, error: null, cause: null, @@ -191,14 +193,14 @@ function subscribeToGitStatus(cwd: string): () => void { }, { onResubscribe: () => { - markGitStatusPending(cwd); + markGitStatusPending(targetKey); }, }, ); } -function markGitStatusPending(cwd: string): void { - const atom = gitStatusStateAtom(cwd); +function markGitStatusPending(targetKey: string): void { + const atom = gitStatusStateAtom(targetKey); const current = appAtomRegistry.get(atom); const next = current.data === null diff --git a/apps/web/src/lib/projectReactQuery.ts b/apps/web/src/lib/projectReactQuery.ts index 20aa265b87..5977129809 100644 --- a/apps/web/src/lib/projectReactQuery.ts +++ b/apps/web/src/lib/projectReactQuery.ts @@ -1,11 +1,15 @@ -import type { ProjectSearchEntriesResult } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts"; import { queryOptions } from "@tanstack/react-query"; -import { ensureNativeApi } from "~/nativeApi"; +import { ensureEnvironmentApi } from "~/environmentApi"; export const projectQueryKeys = { all: ["projects"] as const, - searchEntries: (cwd: string | null, query: string, limit: number) => - ["projects", "search-entries", cwd, query, limit] as const, + searchEntries: ( + environmentId: EnvironmentId | null, + cwd: string | null, + query: string, + limit: number, + ) => ["projects", "search-entries", environmentId ?? null, cwd, query, limit] as const, }; const DEFAULT_SEARCH_ENTRIES_LIMIT = 80; @@ -16,6 +20,7 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { }; export function projectSearchEntriesQueryOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; query: string; enabled?: boolean; @@ -24,19 +29,23 @@ export function projectSearchEntriesQueryOptions(input: { }) { const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT; return queryOptions({ - queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit), + queryKey: projectQueryKeys.searchEntries(input.environmentId, input.cwd, input.query, limit), queryFn: async () => { - const api = ensureNativeApi(); - if (!input.cwd) { + if (!input.cwd || !input.environmentId) { throw new Error("Workspace entry search is unavailable."); } + const api = ensureEnvironmentApi(input.environmentId); return api.projects.searchEntries({ cwd: input.cwd, query: input.query, limit, }); }, - enabled: (input.enabled ?? true) && input.cwd !== null && input.query.length > 0, + enabled: + (input.enabled ?? true) && + input.environmentId !== null && + input.cwd !== null && + input.query.length > 0, staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, }); diff --git a/apps/web/src/lib/providerReactQuery.test.ts b/apps/web/src/lib/providerReactQuery.test.ts index b7e770799a..13361fc1de 100644 --- a/apps/web/src/lib/providerReactQuery.test.ts +++ b/apps/web/src/lib/providerReactQuery.test.ts @@ -1,21 +1,22 @@ -import { ThreadId, type NativeApi } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type EnvironmentApi } from "@t3tools/contracts"; import { QueryClient } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; -import * as nativeApi from "../nativeApi"; +import * as environmentApi from "../environmentApi"; const threadId = ThreadId.makeUnsafe("thread-id"); +const environmentId = EnvironmentId.makeUnsafe("environment-local"); function mockNativeApi(input: { getTurnDiff: ReturnType; getFullThreadDiff: ReturnType; }) { - vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({ + vi.spyOn(environmentApi, "ensureEnvironmentApi").mockReturnValue({ orchestration: { getTurnDiff: input.getTurnDiff, getFullThreadDiff: input.getFullThreadDiff, }, - } as unknown as NativeApi); + } as unknown as EnvironmentApi); } afterEach(() => { @@ -25,6 +26,7 @@ afterEach(() => { describe("providerQueryKeys.checkpointDiff", () => { it("includes cacheScope so reused turn counts do not collide", () => { const baseInput = { + environmentId, threadId, fromTurnCount: 1, toTurnCount: 2, @@ -51,6 +53,7 @@ describe("checkpointDiffQueryOptions", () => { mockNativeApi({ getTurnDiff, getFullThreadDiff }); const options = checkpointDiffQueryOptions({ + environmentId, threadId, fromTurnCount: 3, toTurnCount: 4, @@ -74,6 +77,7 @@ describe("checkpointDiffQueryOptions", () => { mockNativeApi({ getTurnDiff, getFullThreadDiff }); const options = checkpointDiffQueryOptions({ + environmentId, threadId, fromTurnCount: 0, toTurnCount: 2, @@ -96,6 +100,7 @@ describe("checkpointDiffQueryOptions", () => { mockNativeApi({ getTurnDiff, getFullThreadDiff }); const options = checkpointDiffQueryOptions({ + environmentId, threadId, fromTurnCount: 4, toTurnCount: 3, @@ -113,6 +118,7 @@ describe("checkpointDiffQueryOptions", () => { it("retries checkpoint-not-ready errors longer than generic failures", () => { const options = checkpointDiffQueryOptions({ + environmentId, threadId, fromTurnCount: 1, toTurnCount: 2, @@ -137,6 +143,7 @@ describe("checkpointDiffQueryOptions", () => { it("backs off longer for checkpoint-not-ready errors", () => { const options = checkpointDiffQueryOptions({ + environmentId, threadId, fromTurnCount: 1, toTurnCount: 2, diff --git a/apps/web/src/lib/providerReactQuery.ts b/apps/web/src/lib/providerReactQuery.ts index 0547aa9e5f..20007fc8fb 100644 --- a/apps/web/src/lib/providerReactQuery.ts +++ b/apps/web/src/lib/providerReactQuery.ts @@ -1,13 +1,15 @@ import { + type EnvironmentId, OrchestrationGetFullThreadDiffInput, OrchestrationGetTurnDiffInput, ThreadId, } from "@t3tools/contracts"; import { queryOptions } from "@tanstack/react-query"; import { Option, Schema } from "effect"; -import { ensureNativeApi } from "../nativeApi"; +import { ensureEnvironmentApi } from "../environmentApi"; interface CheckpointDiffQueryInput { + environmentId: EnvironmentId | null; threadId: ThreadId | null; fromTurnCount: number | null; toTurnCount: number | null; @@ -21,6 +23,7 @@ export const providerQueryKeys = { [ "providers", "checkpointDiff", + input.environmentId ?? null, input.threadId, input.fromTurnCount, input.toTurnCount, @@ -95,10 +98,10 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { return queryOptions({ queryKey: providerQueryKeys.checkpointDiff(input), queryFn: async () => { - const api = ensureNativeApi(); - if (!input.threadId || decodedRequest._tag === "None") { + if (!input.environmentId || !input.threadId || decodedRequest._tag === "None") { throw new Error("Checkpoint diff is unavailable."); } + const api = ensureEnvironmentApi(input.environmentId); try { if (decodedRequest.value.kind === "fullThreadDiff") { return await api.orchestration.getFullThreadDiff(decodedRequest.value.input); @@ -108,7 +111,11 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { throw new Error(normalizeCheckpointErrorMessage(error), { cause: error }); } }, - enabled: (input.enabled ?? true) && !!input.threadId && decodedRequest._tag === "Some", + enabled: + (input.enabled ?? true) && + !!input.environmentId && + !!input.threadId && + decodedRequest._tag === "Some", staleTime: Infinity, retry: (failureCount, error) => { if (isCheckpointTemporarilyUnavailable(error)) { diff --git a/apps/web/src/lib/terminalStateCleanup.test.ts b/apps/web/src/lib/terminalStateCleanup.test.ts index faf2c477cb..e3348d73e8 100644 --- a/apps/web/src/lib/terminalStateCleanup.test.ts +++ b/apps/web/src/lib/terminalStateCleanup.test.ts @@ -1,58 +1,65 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { collectActiveTerminalThreadIds } from "./terminalStateCleanup"; const threadId = (id: string): ThreadId => ThreadId.makeUnsafe(id); +const threadKey = (environmentId: string, id: string): string => + scopedThreadKey(scopeThreadRef(environmentId as never, threadId(id))); describe("collectActiveTerminalThreadIds", () => { it("retains non-deleted server threads", () => { const activeThreadIds = collectActiveTerminalThreadIds({ snapshotThreads: [ - { id: threadId("server-1"), deletedAt: null, archivedAt: null }, - { id: threadId("server-2"), deletedAt: null, archivedAt: null }, + { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null }, + { key: threadKey("env-b", "server-2"), deletedAt: null, archivedAt: null }, ], - draftThreadIds: [], + draftThreadKeys: [], }); - expect(activeThreadIds).toEqual(new Set([threadId("server-1"), threadId("server-2")])); + expect(activeThreadIds).toEqual( + new Set([threadKey("env-a", "server-1"), threadKey("env-b", "server-2")]), + ); }); it("ignores deleted and archived server threads and keeps local draft threads", () => { const activeThreadIds = collectActiveTerminalThreadIds({ snapshotThreads: [ - { id: threadId("server-active"), deletedAt: null, archivedAt: null }, + { key: threadKey("env-a", "server-active"), deletedAt: null, archivedAt: null }, { - id: threadId("server-deleted"), + key: threadKey("env-a", "server-deleted"), deletedAt: "2026-03-05T08:00:00.000Z", archivedAt: null, }, { - id: threadId("server-archived"), + key: threadKey("env-a", "server-archived"), deletedAt: null, archivedAt: "2026-03-05T09:00:00.000Z", }, ], - draftThreadIds: [threadId("local-draft")], + draftThreadKeys: [threadKey("env-a", "local-draft")], }); - expect(activeThreadIds).toEqual(new Set([threadId("server-active"), threadId("local-draft")])); + expect(activeThreadIds).toEqual( + new Set([threadKey("env-a", "server-active"), threadKey("env-a", "local-draft")]), + ); }); it("does not keep draft-linked terminal state for archived server threads", () => { - const archivedThreadId = threadId("server-archived"); + const archivedThreadId = threadKey("env-a", "server-archived"); const activeThreadIds = collectActiveTerminalThreadIds({ snapshotThreads: [ { - id: archivedThreadId, + key: archivedThreadId, deletedAt: null, archivedAt: "2026-03-05T09:00:00.000Z", }, ], - draftThreadIds: [archivedThreadId, threadId("local-draft")], + draftThreadKeys: [archivedThreadId, threadKey("env-a", "local-draft")], }); - expect(activeThreadIds).toEqual(new Set([threadId("local-draft")])); + expect(activeThreadIds).toEqual(new Set([threadKey("env-a", "local-draft")])); }); }); diff --git a/apps/web/src/lib/terminalStateCleanup.ts b/apps/web/src/lib/terminalStateCleanup.ts index f11b30af92..78660708b5 100644 --- a/apps/web/src/lib/terminalStateCleanup.ts +++ b/apps/web/src/lib/terminalStateCleanup.ts @@ -1,35 +1,33 @@ -import type { ThreadId } from "@t3tools/contracts"; - interface TerminalRetentionThread { - id: ThreadId; + key: string; deletedAt: string | null; archivedAt: string | null; } interface CollectActiveTerminalThreadIdsInput { snapshotThreads: readonly TerminalRetentionThread[]; - draftThreadIds: Iterable; + draftThreadKeys: Iterable; } export function collectActiveTerminalThreadIds( input: CollectActiveTerminalThreadIdsInput, -): Set { - const activeThreadIds = new Set(); - const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.id, thread])); +): Set { + const activeThreadIds = new Set(); + const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread])); for (const thread of input.snapshotThreads) { if (thread.deletedAt !== null) continue; if (thread.archivedAt !== null) continue; - activeThreadIds.add(thread.id); + activeThreadIds.add(thread.key); } - for (const draftThreadId of input.draftThreadIds) { - const snapshotThread = snapshotThreadById.get(draftThreadId); + for (const draftThreadKey of input.draftThreadKeys) { + const snapshotThread = snapshotThreadById.get(draftThreadKey); if ( snapshotThread && (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null) ) { continue; } - activeThreadIds.add(draftThreadId); + activeThreadIds.add(draftThreadKey); } return activeThreadIds; } diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 017b6bee07..317933d677 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -1,6 +1,15 @@ -import { assert, describe, it } from "vitest"; +import { assert, describe, expect, it, vi } from "vitest"; + +const { resolvePrimaryEnvironmentBootstrapUrlMock } = vi.hoisted(() => ({ + resolvePrimaryEnvironmentBootstrapUrlMock: vi.fn(() => "http://bootstrap.test:4321"), +})); + +vi.mock("../environmentBootstrap", () => ({ + resolvePrimaryEnvironmentBootstrapUrl: resolvePrimaryEnvironmentBootstrapUrlMock, +})); import { isWindowsPlatform } from "./utils"; +import { resolveServerUrl } from "./utils"; describe("isWindowsPlatform", () => { it("matches Windows platform identifiers", () => { @@ -13,3 +22,34 @@ describe("isWindowsPlatform", () => { assert.isFalse(isWindowsPlatform("darwin")); }); }); + +describe("resolveServerUrl", () => { + it("falls back to the bootstrap environment URL when the explicit URL is empty", () => { + expect(resolveServerUrl({ url: "" })).toBe("http://bootstrap.test:4321/"); + }); + + it("uses the bootstrap environment URL when no explicit URL is provided", () => { + expect(resolveServerUrl()).toBe("http://bootstrap.test:4321/"); + }); + + it("prefers an explicit URL override", () => { + expect( + resolveServerUrl({ + url: "https://override.test:9999", + protocol: "wss", + pathname: "/rpc", + searchParams: { hello: "world" }, + }), + ).toBe("wss://override.test:9999/rpc?hello=world"); + }); + + it("does not evaluate the bootstrap resolver when an explicit URL is provided", () => { + resolvePrimaryEnvironmentBootstrapUrlMock.mockImplementationOnce(() => { + throw new Error("bootstrap unavailable"); + }); + + expect(resolveServerUrl({ url: "https://override.test:9999" })).toBe( + "https://override.test:9999/", + ); + }); +}); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index e48f815461..27800b3a61 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -4,6 +4,8 @@ import { type CxOptions, cx } from "class-variance-authority"; import { twMerge } from "tailwind-merge"; import * as Random from "effect/Random"; import * as Effect from "effect/Effect"; +import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap"; +import { DraftId } from "../composerDraftStore"; export function cn(...inputs: CxOptions) { return twMerge(cx(inputs)); @@ -34,17 +36,11 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); +export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID()); + export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty); -const firstNonEmptyString = (...values: unknown[]): string => { - for (const value of values) { - if (isNonEmptyString(value)) { - return value; - } - } - throw new Error("No non-empty string provided"); -}; export const resolveServerUrl = (options?: { url?: string | undefined; @@ -52,12 +48,9 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString( - options?.url, - window.desktopBridge?.getWsUrl(), - import.meta.env.VITE_WS_URL, - window.location.origin, - ); + const rawUrl = isNonEmptyString(options?.url) + ? options.url + : resolvePrimaryEnvironmentBootstrapUrl(); const parsedUrl = new URL(rawUrl); if (options?.protocol) { diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/localApi.test.ts similarity index 84% rename from apps/web/src/wsNativeApi.test.ts rename to apps/web/src/localApi.test.ts index ae56f85991..f8d5e531f9 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_SERVER_SETTINGS, type DesktopBridge, + EnvironmentId, EventId, type GitStatusResult, ProjectId, @@ -94,6 +95,17 @@ const rpcClientMock = { vi.mock("./wsRpcClient", () => { return { getWsRpcClient: () => rpcClientMock, + getPrimaryWsRpcClientEntry: () => ({ + key: "primary", + knownEnvironment: { + id: "primary", + label: "Primary", + source: "manual", + target: { type: "ws", wsUrl: "ws://localhost:3000" }, + }, + client: rpcClientMock, + environmentId: null, + }), __resetWsRpcClientForTests: vi.fn(), }; }); @@ -121,6 +133,7 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn function makeDesktopBridge(overrides: Partial = {}): DesktopBridge { return { getWsUrl: () => null, + getLocalEnvironmentBootstrap: () => null, pickFolder: async () => null, confirm: async () => true, setTheme: async () => undefined, @@ -157,7 +170,21 @@ const defaultProviders: ReadonlyArray = [ }, ]; +const baseEnvironment = { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; + const baseServerConfig: ServerConfig = { + environment: baseEnvironment, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], @@ -200,12 +227,12 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("wsNativeApi", () => { +describe("wsApi", () => { it("forwards server config fetches directly to the RPC client", async () => { rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createLocalApi } = await import("./localApi"); - const api = createWsNativeApi(); + const api = createLocalApi(); await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); @@ -214,9 +241,9 @@ describe("wsNativeApi", () => { }); it("forwards terminal and orchestration stream events", async () => { - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); const onTerminalEvent = vi.fn(); const onDomainEvent = vi.fn(); @@ -263,9 +290,9 @@ describe("wsNativeApi", () => { }); it("forwards git status stream events", async () => { - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); const onStatus = vi.fn(); api.git.onStatus({ cwd: "/repo" }, onStatus); @@ -279,9 +306,9 @@ describe("wsNativeApi", () => { it("forwards git status refreshes directly to the RPC client", async () => { rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); await api.git.refreshStatus({ cwd: "/repo" }); @@ -289,9 +316,9 @@ describe("wsNativeApi", () => { }); it("forwards orchestration stream subscription options to the RPC client", async () => { - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); const onDomainEvent = vi.fn(); const onResubscribe = vi.fn(); @@ -304,9 +331,9 @@ describe("wsNativeApi", () => { it("sends orchestration dispatch commands as the direct RPC payload", async () => { rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); const command = { type: "project.create", commandId: CommandId.makeUnsafe("cmd-1"), @@ -326,9 +353,9 @@ describe("wsNativeApi", () => { it("forwards workspace file writes to the project RPC", async () => { rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); await api.projects.writeFile({ cwd: "/tmp/project", relativePath: "plan.md", @@ -344,9 +371,9 @@ describe("wsNativeApi", () => { it("forwards full-thread diff requests to the orchestration RPC", async () => { rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); await api.orchestration.getFullThreadDiff({ threadId: ThreadId.makeUnsafe("thread-1"), toTurnCount: 1, @@ -366,9 +393,9 @@ describe("wsNativeApi", () => { }, ]; rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createLocalApi } = await import("./localApi"); - const api = createWsNativeApi(); + const api = createLocalApi(); await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); @@ -380,9 +407,9 @@ describe("wsNativeApi", () => { enableAssistantStreaming: true, }; rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createLocalApi } = await import("./localApi"); - const api = createWsNativeApi(); + const api = createLocalApi(); await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( nextSettings, @@ -396,8 +423,8 @@ describe("wsNativeApi", () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); - const { createWsNativeApi } = await import("./wsNativeApi"); - const api = createWsNativeApi(); + const { createLocalApi } = await import("./localApi"); + const api = createLocalApi(); const items = [{ id: "delete", label: "Delete" }] as const; await expect(api.contextMenu.show(items)).resolves.toBe("delete"); @@ -406,9 +433,9 @@ describe("wsNativeApi", () => { it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { showContextMenuFallbackMock.mockResolvedValue("rename"); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createLocalApi } = await import("./localApi"); - const api = createWsNativeApi(); + const api = createLocalApi(); const items = [{ id: "rename", label: "Rename" }] as const; await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts new file mode 100644 index 0000000000..3705c9f74a --- /dev/null +++ b/apps/web/src/localApi.ts @@ -0,0 +1,94 @@ +import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; + +import { resetGitStatusStateForTests } from "./lib/gitStatusState"; + +import { __resetWsRpcAtomClientForTests } from "./rpc/client"; +import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; +import { resetServerStateForTests } from "./rpc/serverState"; +import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; +import { getPrimaryWsRpcClientEntry, WsRpcClient, __resetWsRpcClientForTests } from "./wsRpcClient"; +import { showContextMenuFallback } from "./contextMenuFallback"; + +let cachedApi: LocalApi | undefined; + +export function createLocalApi( + rpcClient: WsRpcClient = getPrimaryWsRpcClientEntry().client, +): LocalApi { + return { + dialogs: { + pickFolder: async () => { + if (!window.desktopBridge) return null; + return window.desktopBridge.pickFolder(); + }, + confirm: async (message) => { + if (window.desktopBridge) { + return window.desktopBridge.confirm(message); + } + return window.confirm(message); + }, + }, + shell: { + openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), + openExternal: async (url) => { + if (window.desktopBridge) { + const opened = await window.desktopBridge.openExternal(url); + if (!opened) { + throw new Error("Unable to open link."); + } + return; + } + + window.open(url, "_blank", "noopener,noreferrer"); + }, + }, + contextMenu: { + show: async ( + items: readonly ContextMenuItem[], + position?: { x: number; y: number }, + ): Promise => { + if (window.desktopBridge) { + return window.desktopBridge.showContextMenu(items, position) as Promise; + } + return showContextMenuFallback(items, position); + }, + }, + server: { + getConfig: rpcClient.server.getConfig, + refreshProviders: rpcClient.server.refreshProviders, + upsertKeybinding: rpcClient.server.upsertKeybinding, + getSettings: rpcClient.server.getSettings, + updateSettings: rpcClient.server.updateSettings, + }, + }; +} + +export function readLocalApi(): LocalApi | undefined { + if (typeof window === "undefined") return undefined; + if (cachedApi) return cachedApi; + + if (window.nativeApi) { + cachedApi = window.nativeApi; + return cachedApi; + } + + cachedApi = createLocalApi(); + return cachedApi; +} + +export function ensureLocalApi(): LocalApi { + const api = readLocalApi(); + if (!api) { + throw new Error("Local API not found"); + } + return api; +} + +export async function __resetLocalApiForTests() { + cachedApi = undefined; + await __resetWsRpcAtomClientForTests(); + await __resetWsRpcClientForTests(); + resetGitStatusStateForTests(); + resetRequestLatencyStateForTests(); + resetServerStateForTests(); + resetWsConnectionStateForTests(); +} diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts new file mode 100644 index 0000000000..789441877b --- /dev/null +++ b/apps/web/src/logicalProject.ts @@ -0,0 +1,19 @@ +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import type { ScopedProjectRef } from "@t3tools/contracts"; +import type { Project } from "./types"; + +export function deriveLogicalProjectKey( + project: Pick, +): string { + return ( + project.repositoryIdentity?.canonicalKey ?? + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) + ); +} + +export function deriveLogicalProjectKeyFromRef( + projectRef: ScopedProjectRef, + project: Pick | null | undefined, +): string { + return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); +} diff --git a/apps/web/src/nativeApi.ts b/apps/web/src/nativeApi.ts deleted file mode 100644 index f9b0607347..0000000000 --- a/apps/web/src/nativeApi.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { NativeApi } from "@t3tools/contracts"; - -import { __resetWsNativeApiForTests, createWsNativeApi } from "./wsNativeApi"; - -let cachedApi: NativeApi | undefined; - -export function readNativeApi(): NativeApi | undefined { - if (typeof window === "undefined") return undefined; - if (cachedApi) return cachedApi; - - if (window.nativeApi) { - cachedApi = window.nativeApi; - return cachedApi; - } - - cachedApi = createWsNativeApi(); - return cachedApi; -} - -export function ensureNativeApi(): NativeApi { - const api = readNativeApi(); - if (!api) { - throw new Error("Native API not found"); - } - return api; -} - -export async function __resetNativeApiForTests() { - cachedApi = undefined; - await __resetWsNativeApiForTests(); -} diff --git a/apps/web/src/orchestrationEventEffects.test.ts b/apps/web/src/orchestrationEventEffects.test.ts index 9829ba9455..0e065288ae 100644 --- a/apps/web/src/orchestrationEventEffects.test.ts +++ b/apps/web/src/orchestrationEventEffects.test.ts @@ -68,7 +68,7 @@ describe("deriveOrchestrationBatchEffects", () => { }), ]); - expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]); + expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]); expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]); expect(effects.needsProviderInvalidation).toBe(false); @@ -106,7 +106,7 @@ describe("deriveOrchestrationBatchEffects", () => { }), ]); - expect(effects.clearPromotedDraftThreadIds).toEqual([threadId]); + expect(effects.promoteDraftThreadIds).toEqual([threadId]); expect(effects.clearDeletedThreadIds).toEqual([]); expect(effects.removeTerminalStateThreadIds).toEqual([]); expect(effects.needsProviderInvalidation).toBe(true); @@ -127,7 +127,7 @@ describe("deriveOrchestrationBatchEffects", () => { }), ]); - expect(effects.clearPromotedDraftThreadIds).toEqual([]); + expect(effects.promoteDraftThreadIds).toEqual([]); expect(effects.clearDeletedThreadIds).toEqual([]); expect(effects.removeTerminalStateThreadIds).toEqual([]); }); diff --git a/apps/web/src/orchestrationEventEffects.ts b/apps/web/src/orchestrationEventEffects.ts index b19afa331f..216f07e3e2 100644 --- a/apps/web/src/orchestrationEventEffects.ts +++ b/apps/web/src/orchestrationEventEffects.ts @@ -1,7 +1,7 @@ import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; export interface OrchestrationBatchEffects { - clearPromotedDraftThreadIds: ThreadId[]; + promoteDraftThreadIds: ThreadId[]; clearDeletedThreadIds: ThreadId[]; removeTerminalStateThreadIds: ThreadId[]; needsProviderInvalidation: boolean; @@ -70,12 +70,12 @@ export function deriveOrchestrationBatchEffects( } } - const clearPromotedDraftThreadIds: ThreadId[] = []; + const promoteDraftThreadIds: ThreadId[] = []; const clearDeletedThreadIds: ThreadId[] = []; const removeTerminalStateThreadIds: ThreadId[] = []; for (const [threadId, effect] of threadLifecycleEffects) { if (effect.clearPromotedDraft) { - clearPromotedDraftThreadIds.push(threadId); + promoteDraftThreadIds.push(threadId); } if (effect.clearDeletedThread) { clearDeletedThreadIds.push(threadId); @@ -86,7 +86,7 @@ export function deriveOrchestrationBatchEffects( } return { - clearPromotedDraftThreadIds, + promoteDraftThreadIds, clearDeletedThreadIds, removeTerminalStateThreadIds, needsProviderInvalidation, diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 77b1b15842..dd69738de3 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,7 +14,8 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' -import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' +import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' +import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' const SettingsRoute = SettingsRouteImport.update({ id: '/settings', @@ -40,58 +41,70 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) -const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ - id: '/$threadId', - path: '/$threadId', +const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ + id: '/draft/$draftId', + path: '/draft/$draftId', getParentRoute: () => ChatRoute, } as any) +const ChatEnvironmentIdThreadIdRoute = + ChatEnvironmentIdThreadIdRouteImport.update({ + id: '/$environmentId/$threadId', + path: '/$environmentId/$threadId', + getParentRoute: () => ChatRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/settings': typeof SettingsRouteWithChildren - '/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute + '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren - '/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute '/': typeof ChatIndexRoute + '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren '/settings': typeof SettingsRouteWithChildren - '/_chat/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute '/_chat/': typeof ChatIndexRoute + '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/settings' - | '/$threadId' | '/settings/archived' | '/settings/general' + | '/$environmentId/$threadId' + | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo to: | '/settings' - | '/$threadId' | '/settings/archived' | '/settings/general' | '/' + | '/$environmentId/$threadId' + | '/draft/$draftId' id: | '__root__' | '/_chat' | '/settings' - | '/_chat/$threadId' | '/settings/archived' | '/settings/general' | '/_chat/' + | '/_chat/$environmentId/$threadId' + | '/_chat/draft/$draftId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -136,24 +149,33 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } - '/_chat/$threadId': { - id: '/_chat/$threadId' - path: '/$threadId' - fullPath: '/$threadId' - preLoaderRoute: typeof ChatThreadIdRouteImport + '/_chat/draft/$draftId': { + id: '/_chat/draft/$draftId' + path: '/draft/$draftId' + fullPath: '/draft/$draftId' + preLoaderRoute: typeof ChatDraftDraftIdRouteImport + parentRoute: typeof ChatRoute + } + '/_chat/$environmentId/$threadId': { + id: '/_chat/$environmentId/$threadId' + path: '/$environmentId/$threadId' + fullPath: '/$environmentId/$threadId' + preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport parentRoute: typeof ChatRoute } } } interface ChatRouteChildren { - ChatThreadIdRoute: typeof ChatThreadIdRoute ChatIndexRoute: typeof ChatIndexRoute + ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute + ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute } const ChatRouteChildren: ChatRouteChildren = { - ChatThreadIdRoute: ChatThreadIdRoute, ChatIndexRoute: ChatIndexRoute, + ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, + ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, } const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 48c835ae79..8b24198527 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,8 +1,15 @@ import { + type EnvironmentId, OrchestrationEvent, type ServerLifecycleWelcomePayload, ThreadId, } from "@t3tools/contracts"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { Outlet, createRootRouteWithContext, @@ -10,7 +17,7 @@ import { useNavigate, useLocation, } from "@tanstack/react-router"; -import { useEffect, useEffectEvent, useRef } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; @@ -24,7 +31,7 @@ import { import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { readNativeApi } from "../nativeApi"; +import { readLocalApi } from "../localApi"; import { getServerConfigUpdatedNotification, ServerConfigUpdatedNotification, @@ -34,11 +41,15 @@ import { useServerWelcomeSubscription, } from "../rpc/serverState"; import { - clearPromotedDraftThread, - clearPromotedDraftThreads, + markPromotedDraftThreadByRef, + markPromotedDraftThreadsByRef, useComposerDraftStore, } from "../composerDraftStore"; -import { selectProjects, selectThreadById, selectThreads, useStore } from "../store"; +import { + selectProjectsAcrossEnvironments, + selectThreadsAcrossEnvironments, + useStore, +} from "../store"; import { useUiStateStore } from "../uiStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; import { migrateLocalSettingsToServer } from "../hooks/useSettings"; @@ -48,7 +59,15 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; import { deriveReplayRetryDecision } from "../orchestrationRecovery"; -import { getWsRpcClient } from "~/wsRpcClient"; +import { selectThreadByRef } from "../store"; +import { + bindPrimaryWsRpcClientEnvironment, + bindWsRpcClientEntryEnvironment, + getPrimaryWsRpcClientEntry, + listWsRpcClientEntries, + subscribeWsRpcClientRegistry, + type WsRpcClientEntry, +} from "~/wsRpcClient"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -61,7 +80,7 @@ export const Route = createRootRouteWithContext<{ }); function RootRouteView() { - if (!readNativeApi()) { + if (!readLocalApi()) { return (
@@ -201,14 +220,24 @@ function coalesceOrchestrationUiEvents( const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; +function useRegisteredWsRpcClientEntries(): ReadonlyArray { + const [, setRevision] = useState(0); + + useEffect(() => subscribeWsRpcClientRegistry(() => setRevision((value) => value + 1)), []); + + const entries = listWsRpcClientEntries(); + return entries.length > 0 ? entries : [getPrimaryWsRpcClientEntry()]; +} + function ServerStateBootstrap() { - useEffect(() => startServerStateSync(getWsRpcClient().server), []); + useEffect(() => startServerStateSync(getPrimaryWsRpcClientEntry().client.server), []); return null; } function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); + const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); const syncProjects = useUiStateStore((store) => store.syncProjects); @@ -226,15 +255,22 @@ function EventRouter() { const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); const disposedRef = useRef(false); - const bootstrapFromSnapshotRef = useRef<() => Promise>(async () => undefined); + const bootstrapFromSnapshotRef = useRef<(environmentId: EnvironmentId) => Promise>( + async () => undefined, + ); + const schedulePendingDomainEventFlushRef = useRef<() => void>(() => undefined); const serverConfig = useServerConfig(); + const clientEntries = useRegisteredWsRpcClientEntries(); const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { if (!payload) return; + bindPrimaryWsRpcClientEnvironment(payload.environment.environmentId); + setActiveEnvironmentId(payload.environment.environmentId); + schedulePendingDomainEventFlushRef.current(); migrateLocalSettingsToServer(); void (async () => { - await bootstrapFromSnapshotRef.current(); + await bootstrapFromSnapshotRef.current(payload.environment.environmentId); if (disposedRef.current) { return; } @@ -242,7 +278,12 @@ function EventRouter() { if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - setProjectExpanded(payload.bootstrapProjectId, true); + setProjectExpanded( + scopedProjectKey( + scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), + ), + true, + ); if (readPathname() !== "/") { return; @@ -251,8 +292,11 @@ function EventRouter() { return; } await navigate({ - to: "/$threadId", - params: { threadId: payload.bootstrapThreadId }, + to: "/$environmentId/$threadId", + params: { + environmentId: payload.environment.environmentId, + threadId: payload.bootstrapThreadId, + }, replace: true, }); handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; @@ -289,7 +333,7 @@ function EventRouter() { actionProps: { children: "Open keybindings.json", onClick: () => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api) { return; } @@ -317,40 +361,49 @@ function EventRouter() { ); useEffect(() => { - const api = readNativeApi(); - if (!api) return; + if (!serverConfig) { + return; + } + + bindPrimaryWsRpcClientEnvironment(serverConfig.environment.environmentId); + setActiveEnvironmentId(serverConfig.environment.environmentId); + schedulePendingDomainEventFlushRef.current(); + }, [serverConfig, setActiveEnvironmentId]); + + useEffect(() => { let disposed = false; disposedRef.current = false; - const recovery = createOrchestrationRecoveryCoordinator(); - let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null; let needsProviderInvalidation = false; - const pendingDomainEvents: OrchestrationEvent[] = []; - let flushPendingDomainEventsScheduled = false; + const primaryClientKey = getPrimaryWsRpcClientEntry().key; const reconcileSnapshotDerivedState = () => { const storeState = useStore.getState(); - const threads = selectThreads(storeState); - const projects = selectProjects(storeState); - syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); + const threads = selectThreadsAcrossEnvironments(storeState); + const projects = selectProjectsAcrossEnvironments(storeState); + syncProjects( + projects.map((project) => ({ + key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + cwd: project.cwd, + })), + ); syncThreads( threads.map((thread) => ({ - id: thread.id, + key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), seedVisitedAt: thread.updatedAt ?? thread.createdAt, })), ); - clearPromotedDraftThreads(threads.map((thread) => thread.id)); - const draftThreadIds = Object.keys( - useComposerDraftStore.getState().draftThreadsByThreadId, - ) as ThreadId[]; - const activeThreadIds = collectActiveTerminalThreadIds({ + markPromotedDraftThreadsByRef( + threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), + ); + const activeThreadKeys = collectActiveTerminalThreadIds({ snapshotThreads: threads.map((thread) => ({ - id: thread.id, + key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), deletedAt: null, archivedAt: thread.archivedAt, })), - draftThreadIds, + draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(), }); - removeOrphanedTerminalStates(activeThreadIds); + removeOrphanedTerminalStates(activeThreadKeys); }; const queryInvalidationThrottler = new Throttler( @@ -371,7 +424,11 @@ function EventRouter() { }, ); - const applyEventBatch = (events: ReadonlyArray) => { + const applyEventBatch = ( + events: ReadonlyArray, + environmentId: EnvironmentId, + recovery: ReturnType, + ) => { const nextEvents = recovery.markEventBatchApplied(events); if (nextEvents.length === 0) { return; @@ -391,189 +448,284 @@ function EventRouter() { void queryInvalidationThrottler.maybeExecute(); } - applyOrchestrationEvents(uiEvents); + applyOrchestrationEvents(uiEvents, environmentId); if (needsProjectUiSync) { - const projects = selectProjects(useStore.getState()); - syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); + const projects = selectProjectsAcrossEnvironments(useStore.getState()); + syncProjects( + projects.map((project) => ({ + key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + cwd: project.cwd, + })), + ); } const needsThreadUiSync = nextEvents.some( (event) => event.type === "thread.created" || event.type === "thread.deleted", ); if (needsThreadUiSync) { - const threads = selectThreads(useStore.getState()); + const threads = selectThreadsAcrossEnvironments(useStore.getState()); syncThreads( threads.map((thread) => ({ - id: thread.id, + key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), seedVisitedAt: thread.updatedAt ?? thread.createdAt, })), ); } const draftStore = useComposerDraftStore.getState(); - for (const threadId of batchEffects.clearPromotedDraftThreadIds) { - clearPromotedDraftThread(threadId); + for (const threadId of batchEffects.promoteDraftThreadIds) { + markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId)); } for (const threadId of batchEffects.clearDeletedThreadIds) { - draftStore.clearDraftThread(threadId); - clearThreadUi(threadId); + draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId)); + clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); } for (const threadId of batchEffects.removeTerminalStateThreadIds) { - removeTerminalState(threadId); + removeTerminalState(scopeThreadRef(environmentId, threadId)); } }; - const flushPendingDomainEvents = () => { - flushPendingDomainEventsScheduled = false; - if (disposed || pendingDomainEvents.length === 0) { - return; - } + const clientContexts = clientEntries.map((entry) => { + const recovery = createOrchestrationRecoveryCoordinator(); + let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null; + const pendingDomainEvents: OrchestrationEvent[] = []; + let flushPendingDomainEventsScheduled = false; + let boundEnvironmentId = entry.environmentId; + + const bindEnvironmentId = (environmentId: EnvironmentId) => { + if (boundEnvironmentId === environmentId) { + return; + } + boundEnvironmentId = environmentId; + bindWsRpcClientEntryEnvironment(entry.key, environmentId); + schedulePendingDomainEventFlush(); + }; - const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); - applyEventBatch(events); - }; - const schedulePendingDomainEventFlush = () => { - if (flushPendingDomainEventsScheduled) { - return; - } + const flushPendingDomainEvents = () => { + flushPendingDomainEventsScheduled = false; + if (disposed || pendingDomainEvents.length === 0 || boundEnvironmentId === null) { + return; + } - flushPendingDomainEventsScheduled = true; - queueMicrotask(flushPendingDomainEvents); - }; + const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); + applyEventBatch(events, boundEnvironmentId, recovery); + }; - const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise => { - if (!recovery.beginReplayRecovery(reason)) { - return; - } + const schedulePendingDomainEventFlush = () => { + if (flushPendingDomainEventsScheduled) { + return; + } - const fromSequenceExclusive = recovery.getState().latestSequence; - try { - const events = await api.orchestration.replayEvents(fromSequenceExclusive); - if (!disposed) { - applyEventBatch(events); + flushPendingDomainEventsScheduled = true; + queueMicrotask(flushPendingDomainEvents); + }; + + const runSnapshotRecovery = async ( + reason: "bootstrap" | "replay-failed", + environmentId: EnvironmentId, + ): Promise => { + const started = recovery.beginSnapshotRecovery(reason); + if (import.meta.env.MODE !== "test") { + const state = recovery.getState(); + console.info("[orchestration-recovery]", "Snapshot recovery requested.", { + reason, + clientKey: entry.key, + environmentId, + skipped: !started, + ...(started + ? {} + : { + blockedBy: state.inFlight?.kind ?? null, + blockedByReason: state.inFlight?.reason ?? null, + }), + state, + }); + } + if (!started) { + return; } - } catch { - replayRetryTracker = null; - recovery.failReplayRecovery(); - void fallbackToSnapshotRecovery(); - return; - } - if (!disposed) { - const replayCompletion = recovery.completeReplayRecovery(); - const retryDecision = deriveReplayRetryDecision({ - previousTracker: replayRetryTracker, - completion: replayCompletion, - recoveryState: recovery.getState(), - baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, - maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, - }); - replayRetryTracker = retryDecision.tracker; + try { + const snapshot = await entry.client.orchestration.getSnapshot(); + if (!disposed) { + bindEnvironmentId(environmentId); + syncServerReadModel(snapshot, environmentId); + reconcileSnapshotDerivedState(); + if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { + void runReplayRecovery("sequence-gap"); + } + } + } catch { + recovery.failSnapshotRecovery(); + } + }; - if (retryDecision.shouldRetry) { - if (retryDecision.delayMs > 0) { - await new Promise((resolve) => { - setTimeout(resolve, retryDecision.delayMs); - }); - if (disposed) { + const fallbackToSnapshotRecovery = async (): Promise => { + if (boundEnvironmentId === null) { + return; + } + await runSnapshotRecovery("replay-failed", boundEnvironmentId); + }; + + const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise => { + if (!recovery.beginReplayRecovery(reason)) { + return; + } + + const fromSequenceExclusive = recovery.getState().latestSequence; + try { + const events = await entry.client.orchestration.replayEvents({ fromSequenceExclusive }); + if (!disposed) { + if (boundEnvironmentId === null) { + replayRetryTracker = null; + recovery.failReplayRecovery(); return; } + applyEventBatch(events, boundEnvironmentId, recovery); } - void runReplayRecovery(reason); - } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { - console.warn( - "[orchestration-recovery]", - "Stopping replay recovery after no-progress retries.", - { - state: recovery.getState(), - }, - ); + } catch { + replayRetryTracker = null; + recovery.failReplayRecovery(); + void fallbackToSnapshotRecovery(); + return; } - } - }; - - const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise => { - const started = recovery.beginSnapshotRecovery(reason); - if (import.meta.env.MODE !== "test") { - const state = recovery.getState(); - console.info("[orchestration-recovery]", "Snapshot recovery requested.", { - reason, - skipped: !started, - ...(started - ? {} - : { - blockedBy: state.inFlight?.kind ?? null, - blockedByReason: state.inFlight?.reason ?? null, - }), - state, - }); - } - if (!started) { - return; - } - try { - const snapshot = await api.orchestration.getSnapshot(); if (!disposed) { - syncServerReadModel(snapshot); - reconcileSnapshotDerivedState(); - if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { - void runReplayRecovery("sequence-gap"); + const replayCompletion = recovery.completeReplayRecovery(); + const retryDecision = deriveReplayRetryDecision({ + previousTracker: replayRetryTracker, + completion: replayCompletion, + recoveryState: recovery.getState(), + baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, + maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, + }); + replayRetryTracker = retryDecision.tracker; + + if (retryDecision.shouldRetry) { + if (retryDecision.delayMs > 0) { + await new Promise((resolve) => { + setTimeout(resolve, retryDecision.delayMs); + }); + if (disposed) { + return; + } + } + void runReplayRecovery(reason); + } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { + console.warn( + "[orchestration-recovery]", + "Stopping replay recovery after no-progress retries.", + { + clientKey: entry.key, + environmentId: boundEnvironmentId, + state: recovery.getState(), + }, + ); } } - } catch { - // Keep prior state and wait for welcome or a later replay attempt. - recovery.failSnapshotRecovery(); - } - }; - - const bootstrapFromSnapshot = async (): Promise => { - await runSnapshotRecovery("bootstrap"); - }; - bootstrapFromSnapshotRef.current = bootstrapFromSnapshot; + }; - const fallbackToSnapshotRecovery = async (): Promise => { - await runSnapshotRecovery("replay-failed"); - }; - const unsubDomainEvent = api.orchestration.onDomainEvent( - (event) => { - const action = recovery.classifyDomainEvent(event.sequence); - if (action === "apply") { - pendingDomainEvents.push(event); - schedulePendingDomainEventFlush(); - return; + const unsubLifecycle = entry.client.server.subscribeLifecycle((event) => { + if (event.type === "welcome") { + bindEnvironmentId(event.payload.environment.environmentId); } - if (action === "recover") { - flushPendingDomainEvents(); - void runReplayRecovery("sequence-gap"); + }); + const unsubConfig = entry.client.server.subscribeConfig((event) => { + if (event.type === "snapshot") { + bindEnvironmentId(event.config.environment.environmentId); } - }, - { - onResubscribe: () => { - if (disposed) { + }); + if (boundEnvironmentId === null) { + void entry.client.server + .getConfig() + .then((config) => { + if (!disposed) { + bindEnvironmentId(config.environment.environmentId); + } + }) + .catch(() => undefined); + } + const unsubDomainEvent = entry.client.orchestration.onDomainEvent( + (event) => { + const action = recovery.classifyDomainEvent(event.sequence); + if (action === "apply") { + pendingDomainEvents.push(event); + schedulePendingDomainEventFlush(); return; } - flushPendingDomainEvents(); - void runReplayRecovery("resubscribe"); + if (action === "recover") { + flushPendingDomainEvents(); + void runReplayRecovery("sequence-gap"); + } }, - }, - ); - const unsubTerminalEvent = api.terminal.onEvent((event) => { - const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); - if (thread && thread.archivedAt !== null) { + { + onResubscribe: () => { + if (disposed) { + return; + } + flushPendingDomainEvents(); + void runReplayRecovery("resubscribe"); + }, + }, + ); + const unsubTerminalEvent = entry.client.terminal.onEvent((event) => { + if (boundEnvironmentId === null) { + return; + } + + const threadRef = scopeThreadRef(boundEnvironmentId, ThreadId.makeUnsafe(event.threadId)); + const thread = selectThreadByRef(useStore.getState(), threadRef); + if (!thread || thread.archivedAt !== null) { + return; + } + applyTerminalEvent(threadRef, event); + }); + + return { + key: entry.key, + bindEnvironmentId, + flushPendingDomainEvents, + schedulePendingDomainEventFlush, + runSnapshotRecovery, + cleanup: () => { + flushPendingDomainEventsScheduled = false; + pendingDomainEvents.length = 0; + unsubDomainEvent(); + unsubTerminalEvent(); + unsubLifecycle(); + unsubConfig(); + }, + }; + }); + + schedulePendingDomainEventFlushRef.current = () => { + for (const context of clientContexts) { + context.schedulePendingDomainEventFlush(); + } + }; + + const primaryClientContext = + clientContexts.find((context) => context.key === primaryClientKey) ?? + clientContexts[0] ?? + null; + bootstrapFromSnapshotRef.current = async (environmentId: EnvironmentId) => { + if (!primaryClientContext) { return; } - applyTerminalEvent(event); - }); + primaryClientContext.bindEnvironmentId(environmentId); + await primaryClientContext.runSnapshotRecovery("bootstrap", environmentId); + }; + return () => { disposed = true; disposedRef.current = true; needsProviderInvalidation = false; - flushPendingDomainEventsScheduled = false; - pendingDomainEvents.length = 0; + schedulePendingDomainEventFlushRef.current = () => undefined; queryInvalidationThrottler.cancel(); - unsubDomainEvent(); - unsubTerminalEvent(); + for (const context of clientContexts) { + context.cleanup(); + } }; }, [ applyOrchestrationEvents, + clientEntries, navigate, queryClient, removeTerminalState, @@ -581,6 +733,7 @@ function EventRouter() { applyTerminalEvent, clearThreadUi, setProjectExpanded, + setActiveEnvironmentId, syncProjects, syncServerReadModel, syncThreads, diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx similarity index 73% rename from apps/web/src/routes/_chat.$threadId.tsx rename to apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 99ecc05e7d..9e75ebe7f7 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,8 +1,8 @@ -import { ThreadId } from "@t3tools/contracts"; import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react"; +import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import ChatView from "../components/ChatView"; +import { threadHasStarted } from "../components/ChatView.logic"; import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; import { DiffPanelHeaderSkeleton, @@ -10,14 +10,16 @@ import { DiffPanelShell, type DiffPanelMode, } from "../components/DiffPanelShell"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { type DiffRouteSearch, parseDiffRouteSearch, stripDiffSearchParams, } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; -import { useStore } from "../store"; +import { selectEnvironmentState, selectThreadByRef, useStore } from "../store"; +import { createThreadSelectorByRef } from "../storeSelectors"; +import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; import { Sheet, SheetPopup } from "../components/ui/sheet"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; @@ -161,39 +163,60 @@ const DiffPanelInlineSidebar = (props: { }; function ChatThreadRouteView() { - const bootstrapComplete = useStore((store) => store.bootstrapComplete); const navigate = useNavigate(); - const threadId = Route.useParams({ - select: (params) => ThreadId.makeUnsafe(params.threadId), + const threadRef = Route.useParams({ + select: (params) => resolveThreadRouteRef(params), }); const search = Route.useSearch(); - const threadExists = useStore((store) => store.threadShellById[threadId] !== undefined); + const bootstrapComplete = useStore( + (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, + ); + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const threadExists = useStore((store) => selectThreadByRef(store, threadRef) !== undefined); + const environmentHasServerThreads = useStore( + (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, + ); const draftThreadExists = useComposerDraftStore((store) => - Object.hasOwn(store.draftThreadsByThreadId, threadId), + threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, ); + const draftThread = useComposerDraftStore((store) => + threadRef ? store.getDraftThreadByRef(threadRef) : null, + ); + const environmentHasDraftThreads = useComposerDraftStore((store) => { + if (!threadRef) { + return false; + } + return store.hasDraftThreadsInEnvironment(threadRef.environmentId); + }); const routeThreadExists = threadExists || draftThreadExists; + const serverThreadStarted = threadHasStarted(serverThread); + const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const diffOpen = search.diff === "1"; const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); - // TanStack Router keeps active route components mounted across param-only navigations - // unless remountDeps are configured, so this stays warm across thread switches. const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen); const closeDiff = useCallback(() => { + if (!threadRef) { + return; + } void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), search: { diff: undefined }, }); - }, [navigate, threadId]); + }, [navigate, threadRef]); const openDiff = useCallback(() => { + if (!threadRef) { + return; + } void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1" }; }, }); - }, [navigate, threadId]); + }, [navigate, threadRef]); useEffect(() => { if (diffOpen) { @@ -202,17 +225,23 @@ function ChatThreadRouteView() { }, [diffOpen]); useEffect(() => { - if (!bootstrapComplete) { + if (!threadRef || !bootstrapComplete) { return; } - if (!routeThreadExists) { + if (!routeThreadExists && environmentHasAnyThreads) { void navigate({ to: "/", replace: true }); + } + }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); + + useEffect(() => { + if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) { return; } - }, [bootstrapComplete, navigate, routeThreadExists, threadId]); + finalizePromotedDraftThreadByRef(threadRef); + }, [draftThread?.promotedTo, serverThreadStarted, threadRef]); - if (!bootstrapComplete || !routeThreadExists) { + if (!threadRef || !bootstrapComplete || !routeThreadExists) { return null; } @@ -222,7 +251,11 @@ function ChatThreadRouteView() { return ( <> - + - + {shouldRenderDiffContent ? : null} @@ -246,7 +283,7 @@ function ChatThreadRouteView() { ); } -export const Route = createFileRoute("/_chat/$threadId")({ +export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ validateSearch: (search) => parseDiffRouteSearch(search), search: { middlewares: [retainSearchParams(["diff"])], diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx new file mode 100644 index 0000000000..6ddd78c6bb --- /dev/null +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -0,0 +1,86 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo } from "react"; +import ChatView from "../components/ChatView"; +import { threadHasStarted } from "../components/ChatView.logic"; +import { useComposerDraftStore, DraftId } from "../composerDraftStore"; +import { SidebarInset } from "../components/ui/sidebar"; +import { createThreadSelectorAcrossEnvironments } from "../storeSelectors"; +import { useStore } from "../store"; +import { buildThreadRouteParams } from "../threadRoutes"; + +function DraftChatThreadRouteView() { + const navigate = useNavigate(); + const { draftId: rawDraftId } = Route.useParams(); + const draftId = DraftId.makeUnsafe(rawDraftId); + const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId)); + const serverThread = useStore( + useMemo( + () => createThreadSelectorAcrossEnvironments(draftSession?.threadId ?? null), + [draftSession?.threadId], + ), + ); + const serverThreadStarted = threadHasStarted(serverThread); + const canonicalThreadRef = useMemo( + () => + draftSession?.promotedTo + ? serverThreadStarted + ? draftSession.promotedTo + : null + : serverThread + ? { + environmentId: serverThread.environmentId, + threadId: serverThread.id, + } + : null, + [draftSession?.promotedTo, serverThread, serverThreadStarted], + ); + + useEffect(() => { + if (!canonicalThreadRef) { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(canonicalThreadRef), + replace: true, + }); + }, [canonicalThreadRef, navigate]); + + useEffect(() => { + if (draftSession || canonicalThreadRef) { + return; + } + void navigate({ to: "/", replace: true }); + }, [canonicalThreadRef, draftSession, navigate]); + + if (canonicalThreadRef) { + return ( + + + + ); + } + + if (!draftSession) { + return null; + } + + return ( + + + + ); +} + +export const Route = createFileRoute("/_chat/draft/$draftId")({ + component: DraftChatThreadRouteView, +}); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 1ce840a01a..7491fce005 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,3 +1,4 @@ +import { scopeProjectRef } from "@t3tools/client-runtime"; import { Outlet, createFileRoute } from "@tanstack/react-router"; import { useEffect } from "react"; @@ -12,13 +13,13 @@ import { useServerKeybindings } from "~/rpc/serverState"; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); - const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); - const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } = + const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); + const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useServerKeybindings(); const terminalOpen = useTerminalStateStore((state) => - routeThreadId - ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen + routeThreadRef + ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen : false, ); const appSettings = useSettings(); @@ -27,14 +28,18 @@ function ChatRouteGlobalShortcuts() { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (event.key === "Escape" && selectedThreadIdsSize > 0) { + if (event.key === "Escape" && selectedThreadKeysSize > 0) { event.preventDefault(); clearSelection(); return; } - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId; - if (!projectId) return; + const projectRef = activeThread + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) + : activeDraftThread && routeThreadRef + ? scopeProjectRef(routeThreadRef.environmentId, activeDraftThread.projectId) + : defaultProjectRef; + if (!projectRef) return; const command = resolveShortcutCommand(event, keybindings, { context: { @@ -46,7 +51,7 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectId, { + void handleNewThread(projectRef, { envMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, }), @@ -57,7 +62,7 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectId, { + void handleNewThread(projectRef, { branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, envMode: @@ -77,8 +82,9 @@ function ChatRouteGlobalShortcuts() { clearSelection, handleNewThread, keybindings, - defaultProjectId, - selectedThreadIdsSize, + defaultProjectRef, + routeThreadRef, + selectedThreadKeysSize, terminalOpen, appSettings.defaultThreadEnvMode, ]); diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index 721ce25fb5..4eb198324d 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ProjectId, ThreadId, type ServerConfig, @@ -50,7 +51,21 @@ const defaultProviders: ReadonlyArray = [ }, ]; +const baseEnvironment = { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; + const baseServerConfig: ServerConfig = { + environment: baseEnvironment, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], @@ -193,6 +208,7 @@ describe("serverState", () => { sequence: 1, type: "welcome", payload: { + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), @@ -201,6 +217,7 @@ describe("serverState", () => { }); expect(listener).toHaveBeenCalledWith({ + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), @@ -210,6 +227,7 @@ describe("serverState", () => { const lateListener = vi.fn(); const unsubscribeLate = onWelcome(lateListener); expect(lateListener).toHaveBeenCalledWith({ + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 063f148f9c..ccde42d82b 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,6 +1,7 @@ import { CheckpointRef, DEFAULT_MODEL_BY_PROVIDER, + EnvironmentId, EventId, MessageId, ProjectId, @@ -14,16 +15,49 @@ import { describe, expect, it } from "vitest"; import { applyOrchestrationEvent, applyOrchestrationEvents, - selectProjects, - selectThreads, + selectEnvironmentState, + selectProjectsAcrossEnvironments, + selectThreadsAcrossEnvironments, syncServerReadModel, type AppState, + type EnvironmentState, } from "./store"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + +function withActiveEnvironmentState( + environmentState: EnvironmentState, + overrides: Partial = {}, +): AppState { + const { + activeEnvironmentId: overrideActiveEnvironmentId, + environmentStateById: overrideEnvironmentStateById, + ...environmentOverrides + } = overrides; + const activeEnvironmentId = overrideActiveEnvironmentId ?? localEnvironmentId; + const mergedEnvironmentState = { + ...environmentState, + ...environmentOverrides, + }; + const environmentStateById = + overrideEnvironmentStateById ?? + (activeEnvironmentId + ? { + [activeEnvironmentId]: mergedEnvironmentState, + } + : {}); + + return { + activeEnvironmentId, + environmentStateById, + }; +} + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", @@ -52,6 +86,7 @@ function makeState(thread: Thread): AppState { const projectId = ProjectId.makeUnsafe("project-1"); const project = { id: projectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -62,10 +97,10 @@ function makeState(thread: Thread): AppState { updatedAt: "2026-02-13T00:00:00.000Z", scripts: [], }; - const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { + const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = { [thread.projectId]: [thread.id], }; - return { + const environmentState = { projectIds: [projectId], projectById: { [projectId]: project, @@ -75,6 +110,7 @@ function makeState(thread: Thread): AppState { threadShellById: { [thread.id]: { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -106,7 +142,7 @@ function makeState(thread: Thread): AppState { messageByThreadId: { [thread.id]: Object.fromEntries( thread.messages.map((message) => [message.id, message] as const), - ) as AppState["messageByThreadId"][ThreadId], + ) as EnvironmentState["messageByThreadId"][ThreadId], }, activityIdsByThreadId: { [thread.id]: thread.activities.map((activity) => activity.id), @@ -114,7 +150,7 @@ function makeState(thread: Thread): AppState { activityByThreadId: { [thread.id]: Object.fromEntries( thread.activities.map((activity) => [activity.id, activity] as const), - ) as AppState["activityByThreadId"][ThreadId], + ) as EnvironmentState["activityByThreadId"][ThreadId], }, proposedPlanIdsByThreadId: { [thread.id]: thread.proposedPlans.map((plan) => plan.id), @@ -122,7 +158,7 @@ function makeState(thread: Thread): AppState { proposedPlanByThreadId: { [thread.id]: Object.fromEntries( thread.proposedPlans.map((plan) => [plan.id, plan] as const), - ) as AppState["proposedPlanByThreadId"][ThreadId], + ) as EnvironmentState["proposedPlanByThreadId"][ThreadId], }, turnDiffIdsByThreadId: { [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), @@ -130,15 +166,16 @@ function makeState(thread: Thread): AppState { turnDiffSummaryByThreadId: { [thread.id]: Object.fromEntries( thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), - ) as AppState["turnDiffSummaryByThreadId"][ThreadId], + ) as EnvironmentState["turnDiffSummaryByThreadId"][ThreadId], }, sidebarThreadSummaryById: {}, bootstrapComplete: true, }; + return withActiveEnvironmentState(environmentState); } -function makeEmptyState(overrides: Partial = {}): AppState { - return { +function makeEmptyState(overrides: Partial = {}): AppState { + const environmentState: EnvironmentState = { projectIds: [], projectById: {}, threadIds: [], @@ -156,16 +193,20 @@ function makeEmptyState(overrides: Partial = {}): AppState { turnDiffSummaryByThreadId: {}, sidebarThreadSummaryById: {}, bootstrapComplete: true, - ...overrides, }; + return withActiveEnvironmentState(environmentState, overrides); +} + +function localEnvironmentStateOf(state: AppState): EnvironmentState { + return selectEnvironmentState(state, localEnvironmentId); } function projectsOf(state: AppState) { - return selectProjects(state); + return selectProjectsAcrossEnvironments(state); } function threadsOf(state: AppState) { - return selectThreads(state); + return selectThreadsAcrossEnvironments(state); } function makeEvent( @@ -266,14 +307,20 @@ function makeReadModelProject( describe("store read model sync", () => { it("marks bootstrap complete after snapshot sync", () => { - const initialState: AppState = { - ...makeState(makeThread()), - bootstrapComplete: false, - }; + const initialState = withActiveEnvironmentState( + localEnvironmentStateOf(makeState(makeThread())), + { + bootstrapComplete: false, + }, + ); - const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({}))); + const next = syncServerReadModel( + initialState, + makeReadModel(makeReadModelThread({})), + localEnvironmentId, + ); - expect(next.bootstrapComplete).toBe(true); + expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true); }); it("preserves claude model slugs without an active session", () => { @@ -287,7 +334,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); }); @@ -312,7 +359,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); }); @@ -325,7 +372,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); @@ -341,6 +388,7 @@ describe("store read model sync", () => { archivedAt, }), ), + localEnvironmentId, ); expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); @@ -355,6 +403,7 @@ describe("store read model sync", () => { projectById: { [project2]: { id: project2, + environmentId: localEnvironmentId, name: "Project 2", cwd: "/tmp/project-2", defaultModelSelection: { @@ -367,6 +416,7 @@ describe("store read model sync", () => { }, [project1]: { id: project1, + environmentId: localEnvironmentId, name: "Project 1", cwd: "/tmp/project-1", defaultModelSelection: { @@ -402,7 +452,7 @@ describe("store read model sync", () => { threads: [], }; - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); }); @@ -410,10 +460,9 @@ describe("store read model sync", () => { describe("incremental orchestration updates", () => { it("does not mark bootstrap complete for incremental events", () => { - const state: AppState = { - ...makeState(makeThread()), + const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), { bootstrapComplete: false, - }; + }); const next = applyOrchestrationEvent( state, @@ -422,30 +471,10 @@ describe("incremental orchestration updates", () => { title: "Updated title", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); - expect(next.bootstrapComplete).toBe(false); - }); - - it("updates the existing project title when project.meta-updated arrives", () => { - const projectId = ProjectId.makeUnsafe("project-1"); - const state = makeState( - makeThread({ - projectId, - }), - ); - - const next = applyOrchestrationEvent( - state, - makeEvent("project.meta-updated", { - projectId, - title: "Renamed Project", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - ); - - expect(next.projectById[projectId]?.name).toBe("Renamed Project"); - expect(next.projectById[projectId]?.updatedAt).toBe("2026-02-27T00:00:01.000Z"); + expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(false); }); it("preserves state identity for no-op project and thread deletes", () => { @@ -458,6 +487,7 @@ describe("incremental orchestration updates", () => { projectId: ProjectId.makeUnsafe("project-missing"), deletedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); const nextAfterThreadDelete = applyOrchestrationEvent( state, @@ -465,6 +495,7 @@ describe("incremental orchestration updates", () => { threadId: ThreadId.makeUnsafe("thread-missing"), deletedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(nextAfterProjectDelete).toBe(state); @@ -479,6 +510,7 @@ describe("incremental orchestration updates", () => { projectById: { [originalProjectId]: { id: originalProjectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -506,15 +538,18 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(projectsOf(next)).toHaveLength(1); expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); - expect(next.projectIds).toEqual([recreatedProjectId]); - expect(next.projectById[originalProjectId]).toBeUndefined(); - expect(next.projectById[recreatedProjectId]?.id).toBe(recreatedProjectId); + expect(localEnvironmentStateOf(next).projectIds).toEqual([recreatedProjectId]); + expect(localEnvironmentStateOf(next).projectById[originalProjectId]).toBeUndefined(); + expect(localEnvironmentStateOf(next).projectById[recreatedProjectId]?.id).toBe( + recreatedProjectId, + ); }); it("removes stale project index entries when thread.created recreates a thread under a new project", () => { @@ -525,12 +560,12 @@ describe("incremental orchestration updates", () => { id: threadId, projectId: originalProjectId, }); - const state: AppState = { - ...makeState(thread), + const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(thread)), { projectIds: [originalProjectId, recreatedProjectId], projectById: { [originalProjectId]: { id: originalProjectId, + environmentId: localEnvironmentId, name: "Project 1", cwd: "/tmp/project-1", defaultModelSelection: { @@ -543,6 +578,7 @@ describe("incremental orchestration updates", () => { }, [recreatedProjectId]: { id: recreatedProjectId, + environmentId: localEnvironmentId, name: "Project 2", cwd: "/tmp/project-2", defaultModelSelection: { @@ -554,7 +590,7 @@ describe("incremental orchestration updates", () => { scripts: [], }, }, - }; + }); const next = applyOrchestrationEvent( state, @@ -573,12 +609,15 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)).toHaveLength(1); expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); - expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined(); - expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]); + expect(localEnvironmentStateOf(next).threadIdsByProjectId[originalProjectId]).toBeUndefined(); + expect(localEnvironmentStateOf(next).threadIdsByProjectId[recreatedProjectId]).toEqual([ + threadId, + ]); }); it("updates only the affected thread for message events", () => { @@ -597,13 +636,15 @@ describe("incremental orchestration updates", () => { ], }); const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") }); - const state: AppState = { - ...makeState(thread1), + const baseState = makeState(thread1); + const baseEnvironmentState = localEnvironmentStateOf(baseState); + const state = withActiveEnvironmentState(baseEnvironmentState, { threadIds: [thread1.id, thread2.id], threadShellById: { - ...makeState(thread1).threadShellById, + ...baseEnvironmentState.threadShellById, [thread2.id]: { id: thread2.id, + environmentId: thread2.environmentId, codexThreadId: thread2.codexThreadId, projectId: thread2.projectId, title: thread2.title, @@ -619,54 +660,54 @@ describe("incremental orchestration updates", () => { }, }, threadSessionById: { - ...makeState(thread1).threadSessionById, + ...baseEnvironmentState.threadSessionById, [thread2.id]: thread2.session, }, threadTurnStateById: { - ...makeState(thread1).threadTurnStateById, + ...baseEnvironmentState.threadTurnStateById, [thread2.id]: { latestTurn: thread2.latestTurn, }, }, messageIdsByThreadId: { - ...makeState(thread1).messageIdsByThreadId, + ...baseEnvironmentState.messageIdsByThreadId, [thread2.id]: [], }, messageByThreadId: { - ...makeState(thread1).messageByThreadId, + ...baseEnvironmentState.messageByThreadId, [thread2.id]: {}, }, activityIdsByThreadId: { - ...makeState(thread1).activityIdsByThreadId, + ...baseEnvironmentState.activityIdsByThreadId, [thread2.id]: [], }, activityByThreadId: { - ...makeState(thread1).activityByThreadId, + ...baseEnvironmentState.activityByThreadId, [thread2.id]: {}, }, proposedPlanIdsByThreadId: { - ...makeState(thread1).proposedPlanIdsByThreadId, + ...baseEnvironmentState.proposedPlanIdsByThreadId, [thread2.id]: [], }, proposedPlanByThreadId: { - ...makeState(thread1).proposedPlanByThreadId, + ...baseEnvironmentState.proposedPlanByThreadId, [thread2.id]: {}, }, turnDiffIdsByThreadId: { - ...makeState(thread1).turnDiffIdsByThreadId, + ...baseEnvironmentState.turnDiffIdsByThreadId, [thread2.id]: [], }, turnDiffSummaryByThreadId: { - ...makeState(thread1).turnDiffSummaryByThreadId, + ...baseEnvironmentState.turnDiffSummaryByThreadId, [thread2.id]: {}, }, sidebarThreadSummaryById: { - ...makeState(thread1).sidebarThreadSummaryById, + ...baseEnvironmentState.sidebarThreadSummaryById, }, threadIdsByProjectId: { [thread1.projectId]: [thread1.id, thread2.id], }, - }; + }); const next = applyOrchestrationEvent( state, @@ -680,14 +721,25 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); - expect(next.threadShellById[thread2.id]).toBe(state.threadShellById[thread2.id]); - expect(next.threadSessionById[thread2.id]).toBe(state.threadSessionById[thread2.id]); - expect(next.messageIdsByThreadId[thread2.id]).toBe(state.messageIdsByThreadId[thread2.id]); - expect(next.messageByThreadId[thread2.id]).toBe(state.messageByThreadId[thread2.id]); + const nextEnvironmentState = next.environmentStateById[localEnvironmentId]; + const previousEnvironmentState = state.environmentStateById[localEnvironmentId]; + expect(nextEnvironmentState?.threadShellById[thread2.id]).toBe( + previousEnvironmentState?.threadShellById[thread2.id], + ); + expect(nextEnvironmentState?.threadSessionById[thread2.id]).toBe( + previousEnvironmentState?.threadSessionById[thread2.id], + ); + expect(nextEnvironmentState?.messageIdsByThreadId[thread2.id]).toBe( + previousEnvironmentState?.messageIdsByThreadId[thread2.id], + ); + expect(nextEnvironmentState?.messageByThreadId[thread2.id]).toBe( + previousEnvironmentState?.messageByThreadId[thread2.id], + ); }); it("applies replay batches in sequence and updates session state", () => { @@ -703,38 +755,42 @@ describe("incremental orchestration updates", () => { }); const state = makeState(thread); - const next = applyOrchestrationEvents(state, [ - makeEvent( - "thread.session-set", - { - threadId: thread.id, - session: { + const next = applyOrchestrationEvents( + state, + [ + makeEvent( + "thread.session-set", + { threadId: thread.id, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.makeUnsafe("turn-1"), - lastError: null, - updatedAt: "2026-02-27T00:00:02.000Z", + session: { + threadId: thread.id, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.makeUnsafe("turn-1"), + lastError: null, + updatedAt: "2026-02-27T00:00:02.000Z", + }, }, - }, - { sequence: 2 }, - ), - makeEvent( - "thread.message-sent", - { - threadId: thread.id, - messageId: MessageId.makeUnsafe("assistant-1"), - role: "assistant", - text: "done", - turnId: TurnId.makeUnsafe("turn-1"), - streaming: false, - createdAt: "2026-02-27T00:00:03.000Z", - updatedAt: "2026-02-27T00:00:03.000Z", - }, - { sequence: 3 }, - ), - ]); + { sequence: 2 }, + ), + makeEvent( + "thread.message-sent", + { + threadId: thread.id, + messageId: MessageId.makeUnsafe("assistant-1"), + role: "assistant", + text: "done", + turnId: TurnId.makeUnsafe("turn-1"), + streaming: false, + createdAt: "2026-02-27T00:00:03.000Z", + updatedAt: "2026-02-27T00:00:03.000Z", + }, + { sequence: 3 }, + ), + ], + localEnvironmentId, + ); expect(threadsOf(next)[0]?.session?.status).toBe("running"); expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); @@ -767,6 +823,7 @@ describe("incremental orchestration updates", () => { assistantMessageId: MessageId.makeUnsafe("assistant-1"), completedAt: "2026-02-27T00:00:04.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); @@ -811,6 +868,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:03.000Z", updatedAt: "2026-02-27T00:00:03.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( @@ -920,6 +978,7 @@ describe("incremental orchestration updates", () => { threadId: ThreadId.makeUnsafe("thread-1"), turnCount: 1, }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ @@ -978,6 +1037,7 @@ describe("incremental orchestration updates", () => { threadId: thread.id, turnCount: 1, }), + localEnvironmentId, ); expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); @@ -996,6 +1056,7 @@ describe("incremental orchestration updates", () => { updatedAt: "2026-02-27T00:00:04.000Z", }, }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 4fbb11942c..d5d7d436df 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,4 +1,5 @@ import { + type EnvironmentId, type MessageId, type OrchestrationCheckpointSummary, type OrchestrationEvent, @@ -11,6 +12,8 @@ import { type OrchestrationThreadActivity, type ProjectId, type ProviderKind, + type ScopedProjectRef, + type ScopedThreadRef, ThreadId, type TurnId, } from "@t3tools/contracts"; @@ -35,7 +38,7 @@ import { } from "./types"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; -export interface AppState { +export interface EnvironmentState { projectIds: ProjectId[]; projectById: Record; threadIds: ThreadId[]; @@ -55,7 +58,12 @@ export interface AppState { bootstrapComplete: boolean; } -const initialState: AppState = { +export interface AppState { + activeEnvironmentId: EnvironmentId | null; + environmentStateById: Record; +} + +const initialEnvironmentState: EnvironmentState = { projectIds: [], projectById: {}, threadIds: [], @@ -75,6 +83,11 @@ const initialState: AppState = { bootstrapComplete: false, }; +const initialState: AppState = { + activeEnvironmentId: null, + environmentStateById: {}, +}; + const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; const MAX_THREAD_PROPOSED_PLANS = 200; @@ -169,11 +182,16 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif }; } -function mapProject(project: OrchestrationReadModel["projects"][number]): Project { +function mapProject( + project: OrchestrationReadModel["projects"][number], + environmentId: EnvironmentId, +): Project { return { id: project.id, + environmentId, name: project.title, cwd: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, defaultModelSelection: project.defaultModelSelection ? normalizeModelSelection(project.defaultModelSelection) : null, @@ -183,9 +201,10 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec }; } -function mapThread(thread: OrchestrationThread): Thread { +function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread { return { id: thread.id, + environmentId, codexThreadId: null, projectId: thread.projectId, title: thread.title, @@ -211,6 +230,7 @@ function mapThread(thread: OrchestrationThread): Thread { function toThreadShell(thread: Thread): ThreadShell { return { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -251,6 +271,7 @@ function getLatestUserMessageAt(messages: ReadonlyArray): string | function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { return { id: thread.id, + environmentId: thread.environmentId, projectId: thread.projectId, title: thread.title, interactionMode: thread.interactionMode, @@ -298,6 +319,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b return ( left !== undefined && left.id === right.id && + left.environmentId === right.environmentId && left.codexThreadId === right.codexThreadId && left.projectId === right.projectId && left.title === right.title && @@ -377,7 +399,7 @@ function buildTurnDiffSlice(thread: Thread): { }; } -function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] { +function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] { const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; if (ids.length === 0) { @@ -390,7 +412,7 @@ function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[ } function selectThreadActivities( - state: AppState, + state: EnvironmentState, threadId: ThreadId, ): OrchestrationThreadActivity[] { const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; @@ -404,7 +426,7 @@ function selectThreadActivities( }); } -function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] { +function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] { const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; if (ids.length === 0) { @@ -416,7 +438,10 @@ function selectThreadProposedPlans(state: AppState, threadId: ThreadId): Propose }); } -function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] { +function selectThreadTurnDiffSummaries( + state: EnvironmentState, + threadId: ThreadId, +): TurnDiffSummary[] { const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; if (ids.length === 0) { @@ -428,7 +453,7 @@ function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): Tur }); } -function getThread(state: AppState, threadId: ThreadId): Thread | undefined { +function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined { const shell = state.threadShellById[threadId]; if (!shell) { return undefined; @@ -446,21 +471,25 @@ function getThread(state: AppState, threadId: ThreadId): Thread | undefined { }; } -function getProjects(state: AppState): Project[] { +function getProjects(state: EnvironmentState): Project[] { return state.projectIds.flatMap((projectId) => { const project = state.projectById[projectId]; return project ? [project] : []; }); } -function getThreads(state: AppState): Thread[] { +function getThreads(state: EnvironmentState): Thread[] { return state.threadIds.flatMap((threadId) => { const thread = getThread(state, threadId); return thread ? [thread] : []; }); } -function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState { +function writeThreadState( + state: EnvironmentState, + nextThread: Thread, + previousThread?: Thread, +): EnvironmentState { const nextShell = toThreadShell(nextThread); const nextTurnState = toThreadTurnState(nextThread); const previousShell = state.threadShellById[nextThread.id]; @@ -613,7 +642,7 @@ function writeThreadState(state: AppState, nextThread: Thread, previousThread?: return nextState; } -function removeThreadState(state: AppState, threadId: ThreadId): AppState { +function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { const shell = state.threadShellById[threadId]; if (!shell) { return state; @@ -887,10 +916,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string { } function updateThreadState( - state: AppState, + state: EnvironmentState, threadId: ThreadId, updater: (thread: Thread) => Thread, -): AppState { +): EnvironmentState { const currentThread = getThread(state, threadId); if (!currentThread) { return state; @@ -904,7 +933,7 @@ function updateThreadState( function buildProjectState( projects: ReadonlyArray, -): Pick { +): Pick { return { projectIds: projects.map((project) => project.id), projectById: Object.fromEntries( @@ -916,7 +945,7 @@ function buildProjectState( function buildThreadState( threads: ReadonlyArray, ): Pick< - AppState, + EnvironmentState, | "threadIds" | "threadIdsByProjectId" | "threadShellById" @@ -989,11 +1018,48 @@ function buildThreadState( }; } -export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { +function getStoredEnvironmentState( + state: AppState, + environmentId: EnvironmentId, +): EnvironmentState { + return state.environmentStateById[environmentId] ?? initialEnvironmentState; +} + +function commitEnvironmentState( + state: AppState, + environmentId: EnvironmentId, + nextEnvironmentState: EnvironmentState, +): AppState { + const currentEnvironmentState = state.environmentStateById[environmentId]; + const environmentStateById = + currentEnvironmentState === nextEnvironmentState + ? state.environmentStateById + : { + ...state.environmentStateById, + [environmentId]: nextEnvironmentState, + }; + + if (environmentStateById === state.environmentStateById) { + return state; + } + + return { + ...state, + environmentStateById, + }; +} + +function syncEnvironmentReadModel( + state: EnvironmentState, + readModel: OrchestrationReadModel, + environmentId: EnvironmentId, +): EnvironmentState { const projects = readModel.projects .filter((project) => project.deletedAt === null) - .map(mapProject); - const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); + .map((project) => mapProject(project, environmentId)); + const threads = readModel.threads + .filter((thread) => thread.deletedAt === null) + .map((thread) => mapThread(thread, environmentId)); return { ...state, ...buildProjectState(projects), @@ -1002,19 +1068,43 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea }; } -export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { +export function syncServerReadModel( + state: AppState, + readModel: OrchestrationReadModel, + environmentId: EnvironmentId, +): AppState { + return commitEnvironmentState( + state, + environmentId, + syncEnvironmentReadModel( + getStoredEnvironmentState(state, environmentId), + readModel, + environmentId, + ), + ); +} + +function applyEnvironmentOrchestrationEvent( + state: EnvironmentState, + event: OrchestrationEvent, + environmentId: EnvironmentId, +): EnvironmentState { switch (event.type) { case "project.created": { - const nextProject = mapProject({ - id: event.payload.projectId, - title: event.payload.title, - workspaceRoot: event.payload.workspaceRoot, - defaultModelSelection: event.payload.defaultModelSelection, - scripts: event.payload.scripts, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - deletedAt: null, - }); + const nextProject = mapProject( + { + id: event.payload.projectId, + title: event.payload.title, + workspaceRoot: event.payload.workspaceRoot, + repositoryIdentity: event.payload.repositoryIdentity ?? null, + defaultModelSelection: event.payload.defaultModelSelection, + scripts: event.payload.scripts, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + deletedAt: null, + }, + environmentId, + ); const existingProjectId = state.projectIds.find( (projectId) => @@ -1060,6 +1150,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve ...project, ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), + ...(event.payload.repositoryIdentity !== undefined + ? { repositoryIdentity: event.payload.repositoryIdentity ?? null } + : {}), ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection @@ -1095,26 +1188,29 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve case "thread.created": { const previousThread = getThread(state, event.payload.threadId); - const nextThread = mapThread({ - id: event.payload.threadId, - projectId: event.payload.projectId, - title: event.payload.title, - modelSelection: event.payload.modelSelection, - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - branch: event.payload.branch, - worktreePath: event.payload.worktreePath, - latestTurn: null, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - }); + const nextThread = mapThread( + { + id: event.payload.threadId, + projectId: event.payload.projectId, + title: event.payload.title, + modelSelection: event.payload.modelSelection, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + latestTurn: null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + environmentId, + ); return writeThreadState(state, nextThread, previousThread); } @@ -1481,70 +1577,218 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve export function applyOrchestrationEvents( state: AppState, events: ReadonlyArray, + environmentId: EnvironmentId, ): AppState { if (events.length === 0) { return state; } - return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); -} - -export const selectProjects = (state: AppState): Project[] => getProjects(state); -export const selectThreads = (state: AppState): Thread[] => getThreads(state); -export const selectProjectById = - (projectId: Project["id"] | null | undefined) => - (state: AppState): Project | undefined => - projectId ? state.projectById[projectId] : undefined; -export const selectThreadById = - (threadId: ThreadId | null | undefined) => - (state: AppState): Thread | undefined => - threadId ? getThread(state, threadId) : undefined; -export const selectSidebarThreadSummaryById = - (threadId: ThreadId | null | undefined) => - (state: AppState): SidebarThreadSummary | undefined => - threadId ? state.sidebarThreadSummaryById[threadId] : undefined; -export const selectThreadIdsByProjectId = - (projectId: ProjectId | null | undefined) => - (state: AppState): ThreadId[] => - projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; + const currentEnvironmentState = getStoredEnvironmentState(state, environmentId); + const nextEnvironmentState = events.reduce( + (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId), + currentEnvironmentState, + ); + return commitEnvironmentState(state, environmentId, nextEnvironmentState); +} -export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - return updateThreadState(state, threadId, (thread) => { - if (thread.error === error) return thread; - return { ...thread, error }; +function getEnvironmentEntries( + state: AppState, +): ReadonlyArray { + return Object.entries(state.environmentStateById) as unknown as ReadonlyArray< + readonly [EnvironmentId, EnvironmentState] + >; +} + +export function selectEnvironmentState( + state: AppState, + environmentId: EnvironmentId | null | undefined, +): EnvironmentState { + return environmentId ? getStoredEnvironmentState(state, environmentId) : initialEnvironmentState; +} + +export function selectProjectsForEnvironment( + state: AppState, + environmentId: EnvironmentId | null | undefined, +): Project[] { + return getProjects(selectEnvironmentState(state, environmentId)); +} + +export function selectThreadsForEnvironment( + state: AppState, + environmentId: EnvironmentId | null | undefined, +): Thread[] { + return getThreads(selectEnvironmentState(state, environmentId)); +} + +export function selectProjectsAcrossEnvironments(state: AppState): Project[] { + return getEnvironmentEntries(state).flatMap(([, environmentState]) => + getProjects(environmentState), + ); +} + +export function selectThreadsAcrossEnvironments(state: AppState): Thread[] { + return getEnvironmentEntries(state).flatMap(([, environmentState]) => + getThreads(environmentState), + ); +} + +export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] { + return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) => + environmentState.threadIds.flatMap((threadId) => { + const thread = environmentState.sidebarThreadSummaryById[threadId]; + return thread && thread.environmentId === environmentId ? [thread] : []; + }), + ); +} + +export function selectSidebarThreadsForProjectRef( + state: AppState, + ref: ScopedProjectRef | null | undefined, +): SidebarThreadSummary[] { + if (!ref) { + return []; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? EMPTY_THREAD_IDS; + return threadIds.flatMap((threadId) => { + const thread = environmentState.sidebarThreadSummaryById[threadId]; + return thread ? [thread] : []; }); } +export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean { + return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete; +} + +export function selectProjectByRef( + state: AppState, + ref: ScopedProjectRef | null | undefined, +): Project | undefined { + return ref + ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] + : undefined; +} + +export function selectThreadByRef( + state: AppState, + ref: ScopedThreadRef | null | undefined, +): Thread | undefined { + return ref + ? getThread(selectEnvironmentState(state, ref.environmentId), ref.threadId) + : undefined; +} + +export function selectSidebarThreadSummaryByRef( + state: AppState, + ref: ScopedThreadRef | null | undefined, +): SidebarThreadSummary | undefined { + return ref + ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] + : undefined; +} + +export function selectThreadIdsByProjectRef( + state: AppState, + ref: ScopedProjectRef | null | undefined, +): ThreadId[] { + return ref + ? (selectEnvironmentState(state, ref.environmentId).threadIdsByProjectId[ref.projectId] ?? + EMPTY_THREAD_IDS) + : EMPTY_THREAD_IDS; +} + +export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { + if (state.activeEnvironmentId === null) { + return state; + } + + const nextEnvironmentState = updateThreadState( + getStoredEnvironmentState(state, state.activeEnvironmentId), + threadId, + (thread) => { + if (thread.error === error) return thread; + return { ...thread, error }; + }, + ); + return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); +} + +export function applyOrchestrationEvent( + state: AppState, + event: OrchestrationEvent, + environmentId: EnvironmentId, +): AppState { + return commitEnvironmentState( + state, + environmentId, + applyEnvironmentOrchestrationEvent( + getStoredEnvironmentState(state, environmentId), + event, + environmentId, + ), + ); +} + +export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { + if (state.activeEnvironmentId === environmentId) { + return state; + } + + return { + ...state, + activeEnvironmentId: environmentId, + }; +} + export function setThreadBranch( state: AppState, threadId: ThreadId, branch: string | null, worktreePath: string | null, ): AppState { - return updateThreadState(state, threadId, (thread) => { - if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; - const cwdChanged = thread.worktreePath !== worktreePath; - return { - ...thread, - branch, - worktreePath, - ...(cwdChanged ? { session: null } : {}), - }; - }); + if (state.activeEnvironmentId === null) { + return state; + } + + const nextEnvironmentState = updateThreadState( + getStoredEnvironmentState(state, state.activeEnvironmentId), + threadId, + (thread) => { + if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; + const cwdChanged = thread.worktreePath !== worktreePath; + return { + ...thread, + branch, + worktreePath, + ...(cwdChanged ? { session: null } : {}), + }; + }, + ); + return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); } interface AppStore extends AppState { - syncServerReadModel: (readModel: OrchestrationReadModel) => void; - applyOrchestrationEvent: (event: OrchestrationEvent) => void; - applyOrchestrationEvents: (events: ReadonlyArray) => void; + setActiveEnvironmentId: (environmentId: EnvironmentId) => void; + syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void; + applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; + applyOrchestrationEvents: ( + events: ReadonlyArray, + environmentId: EnvironmentId, + ) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; } export const useStore = create((set) => ({ ...initialState, - syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), - applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), - applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), + setActiveEnvironmentId: (environmentId) => + set((state) => setActiveEnvironmentId(state, environmentId)), + syncServerReadModel: (readModel, environmentId) => + set((state) => syncServerReadModel(state, readModel, environmentId)), + applyOrchestrationEvent: (event, environmentId) => + set((state) => applyOrchestrationEvent(state, event, environmentId)), + applyOrchestrationEvents: (events, environmentId) => + set((state) => applyOrchestrationEvents(state, events, environmentId)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index a7a7440eb2..84802ae6d5 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,5 +1,11 @@ -import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; -import { type AppState } from "./store"; +import { + type MessageId, + type ScopedProjectRef, + type ScopedThreadRef, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; +import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; import { type ChatMessage, type Project, @@ -30,54 +36,61 @@ function collectByIds( }); } -export function createProjectSelector( - projectId: ProjectId | null | undefined, +export function createProjectSelectorByRef( + ref: ScopedProjectRef | null | undefined, ): (state: AppState) => Project | undefined { - return (state) => (projectId ? state.projectById[projectId] : undefined); + return (state) => + ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined; } -export function createSidebarThreadSummarySelector( - threadId: ThreadId | null | undefined, +export function createSidebarThreadSummarySelectorByRef( + ref: ScopedThreadRef | null | undefined, ): (state: AppState) => SidebarThreadSummary | undefined { - return (state) => (threadId ? state.sidebarThreadSummaryById[threadId] : undefined); + return (state) => + ref + ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] + : undefined; } -export function createThreadSelector( - threadId: ThreadId | null | undefined, +function createScopedThreadSelector( + resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, ): (state: AppState) => Thread | undefined { - let previousShell: AppState["threadShellById"][ThreadId] | undefined; + let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined; let previousSession: ThreadSession | null | undefined; let previousTurnState: ThreadTurnState | undefined; let previousMessageIds: MessageId[] | undefined; - let previousMessagesById: AppState["messageByThreadId"][ThreadId] | undefined; + let previousMessagesById: EnvironmentState["messageByThreadId"][ThreadId] | undefined; let previousActivityIds: string[] | undefined; - let previousActivitiesById: AppState["activityByThreadId"][ThreadId] | undefined; + let previousActivitiesById: EnvironmentState["activityByThreadId"][ThreadId] | undefined; let previousProposedPlanIds: string[] | undefined; - let previousProposedPlansById: AppState["proposedPlanByThreadId"][ThreadId] | undefined; + let previousProposedPlansById: EnvironmentState["proposedPlanByThreadId"][ThreadId] | undefined; let previousTurnDiffIds: TurnId[] | undefined; - let previousTurnDiffsById: AppState["turnDiffSummaryByThreadId"][ThreadId] | undefined; + let previousTurnDiffsById: EnvironmentState["turnDiffSummaryByThreadId"][ThreadId] | undefined; let previousThread: Thread | undefined; return (state) => { - if (!threadId) { + const ref = resolveRef(state); + if (!ref) { return undefined; } - const shell = state.threadShellById[threadId]; + const environmentState = selectEnvironmentState(state, ref.environmentId); + const threadId = ref.threadId; + const shell = environmentState.threadShellById[threadId]; if (!shell) { return undefined; } - const session = state.threadSessionById[threadId] ?? null; - const turnState = state.threadTurnStateById[threadId]; - const messageIds = state.messageIdsByThreadId[threadId]; - const messageById = state.messageByThreadId[threadId]; - const activityIds = state.activityIdsByThreadId[threadId]; - const activityById = state.activityByThreadId[threadId]; - const proposedPlanIds = state.proposedPlanIdsByThreadId[threadId]; - const proposedPlanById = state.proposedPlanByThreadId[threadId]; - const turnDiffIds = state.turnDiffIdsByThreadId[threadId]; - const turnDiffById = state.turnDiffSummaryByThreadId[threadId]; + const session = environmentState.threadSessionById[threadId] ?? null; + const turnState = environmentState.threadTurnStateById[threadId]; + const messageIds = environmentState.messageIdsByThreadId[threadId]; + const messageById = environmentState.messageByThreadId[threadId]; + const activityIds = environmentState.activityIdsByThreadId[threadId]; + const activityById = environmentState.activityByThreadId[threadId]; + const proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; + const proposedPlanById = environmentState.proposedPlanByThreadId[threadId]; + const turnDiffIds = environmentState.turnDiffIdsByThreadId[threadId]; + const turnDiffById = environmentState.turnDiffSummaryByThreadId[threadId]; if ( previousThread && @@ -144,3 +157,31 @@ export function createThreadSelector( return previousThread; }; } + +export function createThreadSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => Thread | undefined { + return createScopedThreadSelector(() => ref); +} + +export function createThreadSelectorAcrossEnvironments( + threadId: ThreadId | null | undefined, +): (state: AppState) => Thread | undefined { + return createScopedThreadSelector((state) => { + if (!threadId) { + return undefined; + } + + for (const [environmentId, environmentState] of Object.entries( + state.environmentStateById, + ) as Array<[ScopedThreadRef["environmentId"], EnvironmentState]>) { + if (environmentState.threadShellById[threadId]) { + return { + environmentId, + threadId, + }; + } + } + return undefined; + }); +} diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index 4ded76fba1..37d5ba5d0f 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -1,13 +1,17 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vitest"; import { + migratePersistedTerminalStateStoreState, selectTerminalEventEntries, selectThreadTerminalState, useTerminalStateStore, } from "./terminalStateStore"; const THREAD_ID = ThreadId.makeUnsafe("thread-1"); +const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID); +const OTHER_THREAD_REF = scopeThreadRef("environment-b" as never, THREAD_ID); function makeTerminalEvent( type: TerminalEvent["type"], @@ -56,8 +60,8 @@ describe("terminalStateStore actions", () => { beforeEach(() => { useTerminalStateStore.persist.clearStorage(); useTerminalStateStore.setState({ - terminalStateByThreadId: {}, - terminalLaunchContextByThreadId: {}, + terminalStateByThreadKey: {}, + terminalLaunchContextByThreadKey: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, }); @@ -65,8 +69,8 @@ describe("terminalStateStore actions", () => { it("returns a closed default terminal state for unknown threads", () => { const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState).toEqual({ terminalOpen: false, @@ -81,12 +85,12 @@ describe("terminalStateStore actions", () => { it("opens and splits terminals into the active group", () => { const store = useTerminalStateStore.getState(); - store.setTerminalOpen(THREAD_ID, true); - store.splitTerminal(THREAD_ID, "terminal-2"); + store.setTerminalOpen(THREAD_REF, true); + store.splitTerminal(THREAD_REF, "terminal-2"); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState.terminalOpen).toBe(true); expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); @@ -98,14 +102,14 @@ describe("terminalStateStore actions", () => { it("caps splits at four terminals per group", () => { const store = useTerminalStateStore.getState(); - store.splitTerminal(THREAD_ID, "terminal-2"); - store.splitTerminal(THREAD_ID, "terminal-3"); - store.splitTerminal(THREAD_ID, "terminal-4"); - store.splitTerminal(THREAD_ID, "terminal-5"); + store.splitTerminal(THREAD_REF, "terminal-2"); + store.splitTerminal(THREAD_REF, "terminal-3"); + store.splitTerminal(THREAD_REF, "terminal-4"); + store.splitTerminal(THREAD_REF, "terminal-5"); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState.terminalIds).toEqual([ "default", @@ -119,11 +123,11 @@ describe("terminalStateStore actions", () => { }); it("creates new terminals in a separate group", () => { - useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2"); + useTerminalStateStore.getState().newTerminal(THREAD_REF, "terminal-2"); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); expect(terminalState.activeTerminalId).toBe("terminal-2"); @@ -136,11 +140,11 @@ describe("terminalStateStore actions", () => { it("ensures unknown server terminals are registered, opened, and activated", () => { const store = useTerminalStateStore.getState(); - store.ensureTerminal(THREAD_ID, "setup-setup", { open: true, active: true }); + store.ensureTerminal(THREAD_REF, "setup-setup", { open: true, active: true }); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState.terminalOpen).toBe(true); expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]); @@ -151,69 +155,111 @@ describe("terminalStateStore actions", () => { ]); }); - it("allows unlimited groups while keeping each group capped at four terminals", () => { + it("keeps state isolated per environment when raw thread ids collide", () => { const store = useTerminalStateStore.getState(); - store.splitTerminal(THREAD_ID, "terminal-2"); - store.splitTerminal(THREAD_ID, "terminal-3"); - store.splitTerminal(THREAD_ID, "terminal-4"); - store.newTerminal(THREAD_ID, "terminal-5"); - store.newTerminal(THREAD_ID, "terminal-6"); + store.setTerminalOpen(THREAD_REF, true); + store.newTerminal(OTHER_THREAD_REF, "env-b-terminal"); - const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + expect( + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, + ).terminalOpen, + ).toBe(true); + expect( + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + OTHER_THREAD_REF, + ).terminalIds, + ).toEqual(["default", "env-b-terminal"]); + }); + + it("migrates v1 persisted terminal state using the stored version", () => { + const migrated = migratePersistedTerminalStateStoreState( + { + terminalStateByThreadKey: { + [scopedThreadKey(THREAD_REF)]: { + terminalOpen: true, + terminalHeight: 320, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + }, + "legacy-thread-id": { + terminalOpen: true, + terminalHeight: 320, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + }, + }, + }, + 1, ); - expect(terminalState.terminalIds).toEqual([ - "default", - "terminal-2", - "terminal-3", - "terminal-4", - "terminal-5", - "terminal-6", - ]); - expect(terminalState.terminalGroups).toEqual([ - { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] }, - { id: "group-terminal-5", terminalIds: ["terminal-5"] }, - { id: "group-terminal-6", terminalIds: ["terminal-6"] }, - ]); + + expect(migrated).toEqual({ + terminalStateByThreadKey: { + [scopedThreadKey(THREAD_REF)]: { + terminalOpen: true, + terminalHeight: 320, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + }, + }, + }); }); it("tracks and clears terminal subprocess activity", () => { const store = useTerminalStateStore.getState(); - store.splitTerminal(THREAD_ID, "terminal-2"); - store.setTerminalActivity(THREAD_ID, "terminal-2", true); + store.splitTerminal(THREAD_REF, "terminal-2"); + store.setTerminalActivity(THREAD_REF, "terminal-2", true); expect( - selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) - .runningTerminalIds, + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, + ).runningTerminalIds, ).toEqual(["terminal-2"]); - store.setTerminalActivity(THREAD_ID, "terminal-2", false); + store.setTerminalActivity(THREAD_REF, "terminal-2", false); expect( - selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) - .runningTerminalIds, + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, + ).runningTerminalIds, ).toEqual([]); }); it("resets to default and clears persisted entry when closing the last terminal", () => { const store = useTerminalStateStore.getState(); - store.closeTerminal(THREAD_ID, "default"); + store.closeTerminal(THREAD_REF, "default"); - expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined(); expect( - selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) - .terminalIds, + useTerminalStateStore.getState().terminalStateByThreadKey[scopedThreadKey(THREAD_REF)], + ).toBeUndefined(); + expect( + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, + ).terminalIds, ).toEqual(["default"]); }); it("keeps a valid active terminal after closing an active split terminal", () => { const store = useTerminalStateStore.getState(); - store.splitTerminal(THREAD_ID, "terminal-2"); - store.splitTerminal(THREAD_ID, "terminal-3"); - store.closeTerminal(THREAD_ID, "terminal-3"); + store.splitTerminal(THREAD_REF, "terminal-2"); + store.splitTerminal(THREAD_REF, "terminal-3"); + store.closeTerminal(THREAD_REF, "terminal-3"); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState.activeTerminalId).toBe("terminal-2"); expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); @@ -224,12 +270,12 @@ describe("terminalStateStore actions", () => { it("buffers terminal events outside persisted terminal UI state", () => { const store = useTerminalStateStore.getState(); - store.recordTerminalEvent(makeTerminalEvent("output")); - store.recordTerminalEvent(makeTerminalEvent("activity")); + store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output")); + store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("activity")); const entries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - THREAD_ID, + THREAD_REF, "default", ); @@ -241,6 +287,7 @@ describe("terminalStateStore actions", () => { it("applies started terminal events to terminal state, launch context, and event buffer", () => { const store = useTerminalStateStore.getState(); store.applyTerminalEvent( + THREAD_REF, makeTerminalEvent("started", { terminalId: "setup-bootstrap", snapshot: { @@ -259,19 +306,23 @@ describe("terminalStateStore actions", () => { ); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); const entries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - THREAD_ID, + THREAD_REF, "setup-bootstrap", ); expect(terminalState.terminalOpen).toBe(true); expect(terminalState.activeTerminalId).toBe("setup-bootstrap"); expect(terminalState.terminalIds).toEqual(["default", "setup-bootstrap"]); - expect(useTerminalStateStore.getState().terminalLaunchContextByThreadId[THREAD_ID]).toEqual({ + expect( + useTerminalStateStore.getState().terminalLaunchContextByThreadKey[ + scopedThreadKey(THREAD_REF) + ], + ).toEqual({ cwd: "/tmp/worktree", worktreePath: "/tmp/worktree", }); @@ -281,20 +332,24 @@ describe("terminalStateStore actions", () => { it("applies activity and exited terminal events to subprocess state while buffering events", () => { const store = useTerminalStateStore.getState(); - store.ensureTerminal(THREAD_ID, "terminal-2", { open: true, active: true }); + store.ensureTerminal(THREAD_REF, "terminal-2", { open: true, active: true }); store.applyTerminalEvent( + THREAD_REF, makeTerminalEvent("activity", { terminalId: "terminal-2", hasRunningSubprocess: true, }), ); expect( - selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) - .runningTerminalIds, + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, + ).runningTerminalIds, ).toEqual(["terminal-2"]); store.applyTerminalEvent( + THREAD_REF, makeTerminalEvent("exited", { terminalId: "terminal-2", exitCode: 0, @@ -303,12 +358,12 @@ describe("terminalStateStore actions", () => { ); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); const entries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - THREAD_ID, + THREAD_REF, "terminal-2", ); @@ -318,12 +373,12 @@ describe("terminalStateStore actions", () => { it("clears buffered terminal events when a thread terminal state is removed", () => { const store = useTerminalStateStore.getState(); - store.recordTerminalEvent(makeTerminalEvent("output")); - store.removeTerminalState(THREAD_ID); + store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output")); + store.removeTerminalState(THREAD_REF); const entries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - THREAD_ID, + THREAD_REF, "default", ); @@ -334,7 +389,7 @@ describe("terminalStateStore actions", () => { const store = useTerminalStateStore.getState(); const before = useTerminalStateStore.getState(); - store.clearTerminalState(THREAD_ID); + store.clearTerminalState(THREAD_REF); expect(useTerminalStateStore.getState()).toBe(before); }); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 7189e715a4..962c92f180 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -1,11 +1,12 @@ /** - * Single Zustand store for terminal UI state keyed by threadId. + * Single Zustand store for terminal UI state keyed by scoped thread identity. * * Terminal transition helpers are intentionally private to keep the public * API constrained to store actions/selectors. */ -import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; +import { type ScopedThreadRef, type TerminalEvent } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; @@ -41,6 +42,26 @@ const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray = []; const MAX_TERMINAL_EVENT_BUFFER = 200; +interface PersistedTerminalStateStoreState { + terminalStateByThreadKey?: Record; +} + +export function migratePersistedTerminalStateStoreState( + persistedState: unknown, + version: number, +): PersistedTerminalStateStoreState { + if (version === 1 && persistedState && typeof persistedState === "object") { + const candidate = persistedState as PersistedTerminalStateStoreState; + const nextTerminalStateByThreadKey = Object.fromEntries( + Object.entries(candidate.terminalStateByThreadKey ?? {}).filter(([threadKey]) => + parseScopedThreadKey(threadKey), + ), + ); + return { terminalStateByThreadKey: nextTerminalStateByThreadKey }; + } + return { terminalStateByThreadKey: {} }; +} + function createTerminalStateStorage() { return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); } @@ -240,8 +261,12 @@ function isValidTerminalId(terminalId: string): boolean { return terminalId.trim().length > 0; } -function terminalEventBufferKey(threadId: ThreadId, terminalId: string): string { - return `${threadId}\u0000${terminalId}`; +function terminalThreadKey(threadRef: ScopedThreadRef): string { + return scopedThreadKey(threadRef); +} + +function terminalEventBufferKey(threadRef: ScopedThreadRef, terminalId: string): string { + return `${terminalThreadKey(threadRef)}\u0000${terminalId}`; } function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { @@ -254,9 +279,10 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ function appendTerminalEventEntry( terminalEventEntriesByKey: Record>, nextTerminalEventId: number, + threadRef: ScopedThreadRef, event: TerminalEvent, ) { - const key = terminalEventBufferKey(ThreadId.makeUnsafe(event.threadId), event.terminalId); + const key = terminalEventBufferKey(threadRef, event.terminalId); const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; const nextEntry: TerminalEventEntry = { id: nextTerminalEventId, @@ -484,125 +510,129 @@ function setThreadTerminalActivity( } export function selectThreadTerminalState( - terminalStateByThreadId: Record, - threadId: ThreadId, + terminalStateByThreadKey: Record, + threadRef: ScopedThreadRef | null | undefined, ): ThreadTerminalState { - if (threadId.length === 0) { + if (!threadRef || threadRef.threadId.length === 0) { return getDefaultThreadTerminalState(); } - return terminalStateByThreadId[threadId] ?? getDefaultThreadTerminalState(); + return terminalStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalState(); } -function updateTerminalStateByThreadId( - terminalStateByThreadId: Record, - threadId: ThreadId, +function updateTerminalStateByThreadKey( + terminalStateByThreadKey: Record, + threadRef: ScopedThreadRef, updater: (state: ThreadTerminalState) => ThreadTerminalState, -): Record { - if (threadId.length === 0) { - return terminalStateByThreadId; +): Record { + if (threadRef.threadId.length === 0) { + return terminalStateByThreadKey; } - const current = selectThreadTerminalState(terminalStateByThreadId, threadId); + const threadKey = terminalThreadKey(threadRef); + const current = selectThreadTerminalState(terminalStateByThreadKey, threadRef); const next = updater(current); if (next === current) { - return terminalStateByThreadId; + return terminalStateByThreadKey; } if (isDefaultThreadTerminalState(next)) { - if (terminalStateByThreadId[threadId] === undefined) { - return terminalStateByThreadId; + if (terminalStateByThreadKey[threadKey] === undefined) { + return terminalStateByThreadKey; } - const { [threadId]: _removed, ...rest } = terminalStateByThreadId; - return rest as Record; + const { [threadKey]: _removed, ...rest } = terminalStateByThreadKey; + return rest; } return { - ...terminalStateByThreadId, - [threadId]: next, + ...terminalStateByThreadKey, + [threadKey]: next, }; } export function selectTerminalEventEntries( terminalEventEntriesByKey: Record>, - threadId: ThreadId, + threadRef: ScopedThreadRef | null | undefined, terminalId: string, ): ReadonlyArray { - if (threadId.length === 0 || terminalId.trim().length === 0) { + if (!threadRef || threadRef.threadId.length === 0 || terminalId.trim().length === 0) { return EMPTY_TERMINAL_EVENT_ENTRIES; } return ( - terminalEventEntriesByKey[terminalEventBufferKey(threadId, terminalId)] ?? + terminalEventEntriesByKey[terminalEventBufferKey(threadRef, terminalId)] ?? EMPTY_TERMINAL_EVENT_ENTRIES ); } interface TerminalStateStoreState { - terminalStateByThreadId: Record; - terminalLaunchContextByThreadId: Record; + terminalStateByThreadKey: Record; + terminalLaunchContextByThreadKey: Record; terminalEventEntriesByKey: Record>; nextTerminalEventId: number; - setTerminalOpen: (threadId: ThreadId, open: boolean) => void; - setTerminalHeight: (threadId: ThreadId, height: number) => void; - splitTerminal: (threadId: ThreadId, terminalId: string) => void; - newTerminal: (threadId: ThreadId, terminalId: string) => void; + setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; + setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; + splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; + newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; ensureTerminal: ( - threadId: ThreadId, + threadRef: ScopedThreadRef, terminalId: string, options?: { open?: boolean; active?: boolean }, ) => void; - setActiveTerminal: (threadId: ThreadId, terminalId: string) => void; - closeTerminal: (threadId: ThreadId, terminalId: string) => void; - setTerminalLaunchContext: (threadId: ThreadId, context: ThreadTerminalLaunchContext) => void; - clearTerminalLaunchContext: (threadId: ThreadId) => void; + setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; + closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; + setTerminalLaunchContext: ( + threadRef: ScopedThreadRef, + context: ThreadTerminalLaunchContext, + ) => void; + clearTerminalLaunchContext: (threadRef: ScopedThreadRef) => void; setTerminalActivity: ( - threadId: ThreadId, + threadRef: ScopedThreadRef, terminalId: string, hasRunningSubprocess: boolean, ) => void; - recordTerminalEvent: (event: TerminalEvent) => void; - applyTerminalEvent: (event: TerminalEvent) => void; - clearTerminalState: (threadId: ThreadId) => void; - removeTerminalState: (threadId: ThreadId) => void; - removeOrphanedTerminalStates: (activeThreadIds: Set) => void; + recordTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; + applyTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; + clearTerminalState: (threadRef: ScopedThreadRef) => void; + removeTerminalState: (threadRef: ScopedThreadRef) => void; + removeOrphanedTerminalStates: (activeThreadKeys: Set) => void; } export const useTerminalStateStore = create()( persist( (set) => { const updateTerminal = ( - threadId: ThreadId, + threadRef: ScopedThreadRef, updater: (state: ThreadTerminalState) => ThreadTerminalState, ) => { set((state) => { - const nextTerminalStateByThreadId = updateTerminalStateByThreadId( - state.terminalStateByThreadId, - threadId, + const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( + state.terminalStateByThreadKey, + threadRef, updater, ); - if (nextTerminalStateByThreadId === state.terminalStateByThreadId) { + if (nextTerminalStateByThreadKey === state.terminalStateByThreadKey) { return state; } return { - terminalStateByThreadId: nextTerminalStateByThreadId, + terminalStateByThreadKey: nextTerminalStateByThreadKey, }; }); }; return { - terminalStateByThreadId: {}, - terminalLaunchContextByThreadId: {}, + terminalStateByThreadKey: {}, + terminalLaunchContextByThreadKey: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, - setTerminalOpen: (threadId, open) => - updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), - setTerminalHeight: (threadId, height) => - updateTerminal(threadId, (state) => setThreadTerminalHeight(state, height)), - splitTerminal: (threadId, terminalId) => - updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)), - newTerminal: (threadId, terminalId) => - updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)), - ensureTerminal: (threadId, terminalId, options) => - updateTerminal(threadId, (state) => { + setTerminalOpen: (threadRef, open) => + updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)), + setTerminalHeight: (threadRef, height) => + updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), + splitTerminal: (threadRef, terminalId) => + updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), + newTerminal: (threadRef, terminalId) => + updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), + ensureTerminal: (threadRef, terminalId, options) => + updateTerminal(threadRef, (state) => { let nextState = state; if (!state.terminalIds.includes(terminalId)) { nextState = newThreadTerminal(nextState, terminalId); @@ -622,47 +652,49 @@ export const useTerminalStateStore = create()( } return normalizeThreadTerminalState(nextState); }), - setActiveTerminal: (threadId, terminalId) => - updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)), - closeTerminal: (threadId, terminalId) => - updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)), - setTerminalLaunchContext: (threadId, context) => + setActiveTerminal: (threadRef, terminalId) => + updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), + closeTerminal: (threadRef, terminalId) => + updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), + setTerminalLaunchContext: (threadRef, context) => set((state) => ({ - terminalLaunchContextByThreadId: { - ...state.terminalLaunchContextByThreadId, - [threadId]: context, + terminalLaunchContextByThreadKey: { + ...state.terminalLaunchContextByThreadKey, + [terminalThreadKey(threadRef)]: context, }, })), - clearTerminalLaunchContext: (threadId) => + clearTerminalLaunchContext: (threadRef) => set((state) => { - if (!state.terminalLaunchContextByThreadId[threadId]) { + const threadKey = terminalThreadKey(threadRef); + if (!state.terminalLaunchContextByThreadKey[threadKey]) { return state; } - const { [threadId]: _removed, ...rest } = state.terminalLaunchContextByThreadId; - return { terminalLaunchContextByThreadId: rest }; + const { [threadKey]: _removed, ...rest } = state.terminalLaunchContextByThreadKey; + return { terminalLaunchContextByThreadKey: rest }; }), - setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => - updateTerminal(threadId, (state) => + setTerminalActivity: (threadRef, terminalId, hasRunningSubprocess) => + updateTerminal(threadRef, (state) => setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), ), - recordTerminalEvent: (event) => + recordTerminalEvent: (threadRef, event) => set((state) => appendTerminalEventEntry( state.terminalEventEntriesByKey, state.nextTerminalEventId, + threadRef, event, ), ), - applyTerminalEvent: (event) => + applyTerminalEvent: (threadRef, event) => set((state) => { - const threadId = ThreadId.makeUnsafe(event.threadId); - let nextTerminalStateByThreadId = state.terminalStateByThreadId; - let nextTerminalLaunchContextByThreadId = state.terminalLaunchContextByThreadId; + const threadKey = terminalThreadKey(threadRef); + let nextTerminalStateByThreadKey = state.terminalStateByThreadKey; + let nextTerminalLaunchContextByThreadKey = state.terminalLaunchContextByThreadKey; if (event.type === "started" || event.type === "restarted") { - nextTerminalStateByThreadId = updateTerminalStateByThreadId( - nextTerminalStateByThreadId, - threadId, + nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( + nextTerminalStateByThreadKey, + threadRef, (current) => { let nextState = current; if (!current.terminalIds.includes(event.terminalId)) { @@ -673,17 +705,17 @@ export const useTerminalStateStore = create()( return normalizeThreadTerminalState(nextState); }, ); - nextTerminalLaunchContextByThreadId = { - ...nextTerminalLaunchContextByThreadId, - [threadId]: launchContextFromStartEvent(event), + nextTerminalLaunchContextByThreadKey = { + ...nextTerminalLaunchContextByThreadKey, + [threadKey]: launchContextFromStartEvent(event), }; } const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); if (hasRunningSubprocess !== null) { - nextTerminalStateByThreadId = updateTerminalStateByThreadId( - nextTerminalStateByThreadId, - threadId, + nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( + nextTerminalStateByThreadKey, + threadRef, (current) => setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess), ); @@ -692,54 +724,59 @@ export const useTerminalStateStore = create()( const nextEventState = appendTerminalEventEntry( state.terminalEventEntriesByKey, state.nextTerminalEventId, + threadRef, event, ); return { - terminalStateByThreadId: nextTerminalStateByThreadId, - terminalLaunchContextByThreadId: nextTerminalLaunchContextByThreadId, + terminalStateByThreadKey: nextTerminalStateByThreadKey, + terminalLaunchContextByThreadKey: nextTerminalLaunchContextByThreadKey, ...nextEventState, }; }), - clearTerminalState: (threadId) => + clearTerminalState: (threadRef) => set((state) => { - const nextTerminalStateByThreadId = updateTerminalStateByThreadId( - state.terminalStateByThreadId, - threadId, + const threadKey = terminalThreadKey(threadRef); + const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( + state.terminalStateByThreadKey, + threadRef, () => createDefaultThreadTerminalState(), ); - const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; - const { [threadId]: _removed, ...remainingLaunchContexts } = - state.terminalLaunchContextByThreadId; + const hadLaunchContext = + state.terminalLaunchContextByThreadKey[threadKey] !== undefined; + const { [threadKey]: _removed, ...remainingLaunchContexts } = + state.terminalLaunchContextByThreadKey; const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; let removedEventEntries = false; for (const key of Object.keys(nextTerminalEventEntriesByKey)) { - if (key.startsWith(`${threadId}\u0000`)) { + if (key.startsWith(`${threadKey}\u0000`)) { delete nextTerminalEventEntriesByKey[key]; removedEventEntries = true; } } if ( - nextTerminalStateByThreadId === state.terminalStateByThreadId && + nextTerminalStateByThreadKey === state.terminalStateByThreadKey && !hadLaunchContext && !removedEventEntries ) { return state; } return { - terminalStateByThreadId: nextTerminalStateByThreadId, - terminalLaunchContextByThreadId: remainingLaunchContexts, + terminalStateByThreadKey: nextTerminalStateByThreadKey, + terminalLaunchContextByThreadKey: remainingLaunchContexts, terminalEventEntriesByKey: nextTerminalEventEntriesByKey, }; }), - removeTerminalState: (threadId) => + removeTerminalState: (threadRef) => set((state) => { - const hadTerminalState = state.terminalStateByThreadId[threadId] !== undefined; - const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; + const threadKey = terminalThreadKey(threadRef); + const hadTerminalState = state.terminalStateByThreadKey[threadKey] !== undefined; + const hadLaunchContext = + state.terminalLaunchContextByThreadKey[threadKey] !== undefined; const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; let removedEventEntries = false; for (const key of Object.keys(nextTerminalEventEntriesByKey)) { - if (key.startsWith(`${threadId}\u0000`)) { + if (key.startsWith(`${threadKey}\u0000`)) { delete nextTerminalEventEntriesByKey[key]; removedEventEntries = true; } @@ -747,29 +784,29 @@ export const useTerminalStateStore = create()( if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) { return state; } - const nextTerminalStateByThreadId = { ...state.terminalStateByThreadId }; - delete nextTerminalStateByThreadId[threadId]; - const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId }; - delete nextLaunchContexts[threadId]; + const nextTerminalStateByThreadKey = { ...state.terminalStateByThreadKey }; + delete nextTerminalStateByThreadKey[threadKey]; + const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey }; + delete nextLaunchContexts[threadKey]; return { - terminalStateByThreadId: nextTerminalStateByThreadId, - terminalLaunchContextByThreadId: nextLaunchContexts, + terminalStateByThreadKey: nextTerminalStateByThreadKey, + terminalLaunchContextByThreadKey: nextLaunchContexts, terminalEventEntriesByKey: nextTerminalEventEntriesByKey, }; }), - removeOrphanedTerminalStates: (activeThreadIds) => + removeOrphanedTerminalStates: (activeThreadKeys) => set((state) => { - const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( - (id) => !activeThreadIds.has(id as ThreadId), + const orphanedIds = Object.keys(state.terminalStateByThreadKey).filter( + (key) => !activeThreadKeys.has(key), ); const orphanedLaunchContextIds = Object.keys( - state.terminalLaunchContextByThreadId, - ).filter((id) => !activeThreadIds.has(id as ThreadId)); + state.terminalLaunchContextByThreadKey, + ).filter((key) => !activeThreadKeys.has(key)); const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; let removedEventEntries = false; for (const key of Object.keys(nextTerminalEventEntriesByKey)) { - const [threadId] = key.split("\u0000"); - if (threadId && !activeThreadIds.has(threadId as ThreadId)) { + const [threadKey] = key.split("\u0000"); + if (threadKey && !activeThreadKeys.has(threadKey)) { delete nextTerminalEventEntriesByKey[key]; removedEventEntries = true; } @@ -781,17 +818,17 @@ export const useTerminalStateStore = create()( ) { return state; } - const next = { ...state.terminalStateByThreadId }; + const next = { ...state.terminalStateByThreadKey }; for (const id of orphanedIds) { - delete next[id as ThreadId]; + delete next[id]; } - const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId }; + const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey }; for (const id of orphanedLaunchContextIds) { - delete nextLaunchContexts[id as ThreadId]; + delete nextLaunchContexts[id]; } return { - terminalStateByThreadId: next, - terminalLaunchContextByThreadId: nextLaunchContexts, + terminalStateByThreadKey: next, + terminalLaunchContextByThreadKey: nextLaunchContexts, terminalEventEntriesByKey: nextTerminalEventEntriesByKey, }; }), @@ -799,10 +836,11 @@ export const useTerminalStateStore = create()( }, { name: TERMINAL_STATE_STORAGE_KEY, - version: 1, + version: 2, storage: createJSONStorage(createTerminalStateStorage), + migrate: migratePersistedTerminalStateStoreState, partialize: (state) => ({ - terminalStateByThreadId: state.terminalStateByThreadId, + terminalStateByThreadKey: state.terminalStateByThreadKey, }), }, ), diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts new file mode 100644 index 0000000000..b2010dd16d --- /dev/null +++ b/apps/web/src/threadRoutes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { ThreadId } from "@t3tools/contracts"; +import { DraftId } from "./composerDraftStore"; + +import { + buildDraftThreadRouteParams, + buildThreadRouteParams, + resolveThreadRouteRef, + resolveThreadRouteTarget, +} from "./threadRoutes"; + +describe("threadRoutes", () => { + it("builds canonical thread route params from a scoped ref", () => { + const ref = scopeThreadRef("env-1" as never, ThreadId.makeUnsafe("thread-1")); + + expect(buildThreadRouteParams(ref)).toEqual({ + environmentId: "env-1", + threadId: "thread-1", + }); + }); + + it("resolves a scoped ref only when both params are present", () => { + expect( + resolveThreadRouteRef({ + environmentId: "env-1", + threadId: "thread-1", + }), + ).toEqual({ + environmentId: "env-1", + threadId: "thread-1", + }); + + expect(resolveThreadRouteRef({ environmentId: "env-1" })).toBeNull(); + expect(resolveThreadRouteRef({ threadId: "thread-1" })).toBeNull(); + }); + + it("builds canonical draft route params from a draft id", () => { + expect(buildDraftThreadRouteParams(DraftId.makeUnsafe("draft-1"))).toEqual({ + draftId: "draft-1", + }); + }); + + it("resolves draft and server route targets", () => { + expect( + resolveThreadRouteTarget({ + environmentId: "env-1", + threadId: "thread-1", + }), + ).toEqual({ + kind: "server", + threadRef: { + environmentId: "env-1", + threadId: "thread-1", + }, + }); + + expect( + resolveThreadRouteTarget({ + draftId: "draft-1", + }), + ).toEqual({ + kind: "draft", + draftId: "draft-1", + }); + }); +}); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts new file mode 100644 index 0000000000..3fda9eb423 --- /dev/null +++ b/apps/web/src/threadRoutes.ts @@ -0,0 +1,59 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import type { DraftId } from "./composerDraftStore"; + +export type ThreadRouteTarget = + | { + kind: "server"; + threadRef: ScopedThreadRef; + } + | { + kind: "draft"; + draftId: DraftId; + }; + +export function buildThreadRouteParams(ref: ScopedThreadRef): { + environmentId: EnvironmentId; + threadId: ThreadId; +} { + return { + environmentId: ref.environmentId, + threadId: ref.threadId, + }; +} + +export function buildDraftThreadRouteParams(draftId: DraftId): { + draftId: DraftId; +} { + return { draftId }; +} + +export function resolveThreadRouteRef( + params: Partial>, +): ScopedThreadRef | null { + if (!params.environmentId || !params.threadId) { + return null; + } + + return scopeThreadRef(params.environmentId as EnvironmentId, params.threadId as ThreadId); +} + +export function resolveThreadRouteTarget( + params: Partial>, +): ThreadRouteTarget | null { + if (params.environmentId && params.threadId) { + return { + kind: "server", + threadRef: scopeThreadRef(params.environmentId as EnvironmentId, params.threadId as ThreadId), + }; + } + + if (!params.draftId) { + return null; + } + + return { + kind: "draft", + draftId: params.draftId as DraftId, + }; +} diff --git a/apps/web/src/threadSelectionStore.test.ts b/apps/web/src/threadSelectionStore.test.ts index b142c5c7d2..818cebc72d 100644 --- a/apps/web/src/threadSelectionStore.test.ts +++ b/apps/web/src/threadSelectionStore.test.ts @@ -21,9 +21,9 @@ describe("threadSelectionStore", () => { useThreadSelectionStore.getState().toggleThread(THREAD_A); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); - expect(state.selectedThreadIds.size).toBe(1); - expect(state.anchorThreadId).toBe(THREAD_A); + expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(1); + expect(state.anchorThreadKey).toBe(THREAD_A); }); it("removes a thread that is already selected", () => { @@ -32,8 +32,8 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_A); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_A)).toBe(false); - expect(state.selectedThreadIds.size).toBe(0); + expect(state.selectedThreadKeys.has(THREAD_A)).toBe(false); + expect(state.selectedThreadKeys.size).toBe(0); }); it("preserves existing selections when toggling a new thread", () => { @@ -42,9 +42,9 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_B); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.size).toBe(2); + expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(2); }); it("sets anchor to the newly added thread", () => { @@ -52,7 +52,7 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); - expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); }); it("preserves anchor when deselecting a non-anchor thread", () => { @@ -61,7 +61,7 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_B); store.toggleThread(THREAD_A); // deselect A, anchor should stay B - expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); }); }); @@ -70,8 +70,8 @@ describe("threadSelectionStore", () => { useThreadSelectionStore.getState().setAnchor(THREAD_B); const state = useThreadSelectionStore.getState(); - expect(state.anchorThreadId).toBe(THREAD_B); - expect(state.selectedThreadIds.size).toBe(0); + expect(state.anchorThreadKey).toBe(THREAD_B); + expect(state.selectedThreadKeys.size).toBe(0); }); it("enables range select from a plain-click anchor", () => { @@ -80,10 +80,10 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); - expect(state.selectedThreadIds.size).toBe(3); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(3); }); it("is a no-op when anchor is already set to the same thread", () => { @@ -105,8 +105,8 @@ describe("threadSelectionStore", () => { store.setAnchor(THREAD_C); const state = useThreadSelectionStore.getState(); - expect(state.anchorThreadId).toBe(THREAD_C); - expect(state.selectedThreadIds.size).toBe(0); + expect(state.anchorThreadKey).toBe(THREAD_C); + expect(state.selectedThreadKeys.size).toBe(0); }); }); @@ -115,9 +115,9 @@ describe("threadSelectionStore", () => { useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.size).toBe(1); - expect(state.anchorThreadId).toBe(THREAD_C); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(1); + expect(state.anchorThreadKey).toBe(THREAD_C); }); it("selects range from anchor to target (forward)", () => { @@ -126,10 +126,10 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_D, ORDERED); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); - expect(state.selectedThreadIds.size).toBe(3); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(3); }); it("selects range from anchor to target (backward)", () => { @@ -138,10 +138,10 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_B, ORDERED); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); - expect(state.selectedThreadIds.size).toBe(3); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(3); }); it("keeps anchor stable across multiple range selects", () => { @@ -151,11 +151,11 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B) const state = useThreadSelectionStore.getState(); - expect(state.anchorThreadId).toBe(THREAD_B); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_E)).toBe(true); + expect(state.anchorThreadKey).toBe(THREAD_B); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_E)).toBe(true); }); it("falls back to toggle when anchor is not in the ordered list", () => { @@ -166,8 +166,8 @@ describe("threadSelectionStore", () => { const state = useThreadSelectionStore.getState(); // Should have added C and reset anchor to C - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.anchorThreadId).toBe(THREAD_C); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.anchorThreadKey).toBe(THREAD_C); }); it("falls back to toggle when target is not in the ordered list", () => { @@ -177,8 +177,8 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(unknownThread, ORDERED); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(unknownThread)).toBe(true); - expect(state.anchorThreadId).toBe(unknownThread); + expect(state.selectedThreadKeys.has(unknownThread)).toBe(true); + expect(state.anchorThreadKey).toBe(unknownThread); }); it("selects the single thread when anchor equals target", () => { @@ -187,8 +187,8 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.size).toBe(1); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(1); }); it("preserves previously selected threads outside the range", () => { @@ -200,11 +200,11 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_D, ORDERED); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); - expect(state.selectedThreadIds.size).toBe(4); + expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(4); }); }); @@ -216,8 +216,8 @@ describe("threadSelectionStore", () => { store.clearSelection(); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.size).toBe(0); - expect(state.anchorThreadId).toBeNull(); + expect(state.selectedThreadKeys.size).toBe(0); + expect(state.anchorThreadKey).toBeNull(); }); it("is a no-op when already empty", () => { @@ -226,7 +226,7 @@ describe("threadSelectionStore", () => { const stateAfter = useThreadSelectionStore.getState(); // Should be referentially the same (no unnecessary re-render) - expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds); + expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys); }); }); @@ -239,8 +239,8 @@ describe("threadSelectionStore", () => { store.removeFromSelection([THREAD_A, THREAD_C]); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.size).toBe(1); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(1); }); it("clears anchor when the anchor thread is removed", () => { @@ -249,7 +249,7 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_B); // anchor = B store.removeFromSelection([THREAD_B]); - expect(useThreadSelectionStore.getState().anchorThreadId).toBeNull(); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull(); }); it("preserves anchor when the anchor thread is not removed", () => { @@ -258,7 +258,7 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_B); // anchor = B store.removeFromSelection([THREAD_A]); - expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); }); it("is a no-op when none of the specified threads are selected", () => { @@ -268,7 +268,7 @@ describe("threadSelectionStore", () => { store.removeFromSelection([THREAD_B, THREAD_C]); const stateAfter = useThreadSelectionStore.getState(); - expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds); + expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys); }); }); diff --git a/apps/web/src/threadSelectionStore.ts b/apps/web/src/threadSelectionStore.ts index 8360bc5c6f..2b4022a68f 100644 --- a/apps/web/src/threadSelectionStore.ts +++ b/apps/web/src/threadSelectionStore.ts @@ -4,121 +4,119 @@ * Supports Cmd/Ctrl+Click (toggle individual), Shift+Click (range select), * and bulk actions on the selected set. */ - -import type { ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; export interface ThreadSelectionState { - /** Currently selected thread IDs. */ - selectedThreadIds: ReadonlySet; - /** The thread ID that anchors shift-click range selection. */ - anchorThreadId: ThreadId | null; + /** Currently selected scoped thread keys. */ + selectedThreadKeys: ReadonlySet; + /** The scoped thread key that anchors shift-click range selection. */ + anchorThreadKey: string | null; } interface ThreadSelectionStore extends ThreadSelectionState { - /** Toggle a single thread in the selection (Cmd/Ctrl+Click). */ - toggleThread: (threadId: ThreadId) => void; + /** Toggle a single scoped thread key in the selection (Cmd/Ctrl+Click). */ + toggleThread: (threadKey: string) => void; /** * Select a range of threads (Shift+Click). - * Requires the ordered list of thread IDs within the same project + * Requires the ordered list of scoped thread keys within the same project * so the store can compute which threads fall between anchor and target. */ - rangeSelectTo: (threadId: ThreadId, orderedThreadIds: readonly ThreadId[]) => void; + rangeSelectTo: (threadKey: string, orderedThreadKeys: readonly string[]) => void; /** Clear all selection state. */ clearSelection: () => void; - /** Remove specific thread IDs from the selection (e.g. after deletion). */ - removeFromSelection: (threadIds: readonly ThreadId[]) => void; + /** Remove specific scoped thread keys from the selection (e.g. after deletion). */ + removeFromSelection: (threadKeys: readonly string[]) => void; /** Set the anchor thread without adding it to the selection (e.g. on plain-click navigate). */ - setAnchor: (threadId: ThreadId) => void; + setAnchor: (threadKey: string) => void; /** Check if any threads are selected. */ hasSelection: () => boolean; } -const EMPTY_SET = new Set(); +const EMPTY_SET = new Set(); export const useThreadSelectionStore = create((set, get) => ({ - selectedThreadIds: EMPTY_SET, - anchorThreadId: null, + selectedThreadKeys: EMPTY_SET, + anchorThreadKey: null, - toggleThread: (threadId) => { + toggleThread: (threadKey) => { set((state) => { - const next = new Set(state.selectedThreadIds); - if (next.has(threadId)) { - next.delete(threadId); + const next = new Set(state.selectedThreadKeys); + if (next.has(threadKey)) { + next.delete(threadKey); } else { - next.add(threadId); + next.add(threadKey); } return { - selectedThreadIds: next, - anchorThreadId: next.has(threadId) ? threadId : state.anchorThreadId, + selectedThreadKeys: next, + anchorThreadKey: next.has(threadKey) ? threadKey : state.anchorThreadKey, }; }); }, - rangeSelectTo: (threadId, orderedThreadIds) => { + rangeSelectTo: (threadKey, orderedThreadKeys) => { set((state) => { - const anchor = state.anchorThreadId; + const anchor = state.anchorThreadKey; if (anchor === null) { // No anchor yet — treat as a single toggle - const next = new Set(state.selectedThreadIds); - next.add(threadId); - return { selectedThreadIds: next, anchorThreadId: threadId }; + const next = new Set(state.selectedThreadKeys); + next.add(threadKey); + return { selectedThreadKeys: next, anchorThreadKey: threadKey }; } - const anchorIndex = orderedThreadIds.indexOf(anchor); - const targetIndex = orderedThreadIds.indexOf(threadId); + const anchorIndex = orderedThreadKeys.indexOf(anchor); + const targetIndex = orderedThreadKeys.indexOf(threadKey); if (anchorIndex === -1 || targetIndex === -1) { // Anchor or target not in this list (different project?) — fallback to toggle - const next = new Set(state.selectedThreadIds); - next.add(threadId); - return { selectedThreadIds: next, anchorThreadId: threadId }; + const next = new Set(state.selectedThreadKeys); + next.add(threadKey); + return { selectedThreadKeys: next, anchorThreadKey: threadKey }; } const start = Math.min(anchorIndex, targetIndex); const end = Math.max(anchorIndex, targetIndex); - const next = new Set(state.selectedThreadIds); + const next = new Set(state.selectedThreadKeys); for (let i = start; i <= end; i++) { - const id = orderedThreadIds[i]; - if (id !== undefined) { - next.add(id); + const key = orderedThreadKeys[i]; + if (key !== undefined) { + next.add(key); } } // Keep anchor stable so subsequent shift-clicks extend from the same point - return { selectedThreadIds: next, anchorThreadId: anchor }; + return { selectedThreadKeys: next, anchorThreadKey: anchor }; }); }, clearSelection: () => { const state = get(); - if (state.selectedThreadIds.size === 0 && state.anchorThreadId === null) return; - set({ selectedThreadIds: EMPTY_SET, anchorThreadId: null }); + if (state.selectedThreadKeys.size === 0 && state.anchorThreadKey === null) return; + set({ selectedThreadKeys: EMPTY_SET, anchorThreadKey: null }); }, - setAnchor: (threadId) => { - if (get().anchorThreadId === threadId) return; - set({ anchorThreadId: threadId }); + setAnchor: (threadKey) => { + if (get().anchorThreadKey === threadKey) return; + set({ anchorThreadKey: threadKey }); }, - removeFromSelection: (threadIds) => { + removeFromSelection: (threadKeys) => { set((state) => { - const toRemove = new Set(threadIds); + const toRemove = new Set(threadKeys); let changed = false; - const next = new Set(); - for (const id of state.selectedThreadIds) { - if (toRemove.has(id)) { + const next = new Set(); + for (const key of state.selectedThreadKeys) { + if (toRemove.has(key)) { changed = true; } else { - next.add(id); + next.add(key); } } if (!changed) return state; const newAnchor = - state.anchorThreadId !== null && toRemove.has(state.anchorThreadId) + state.anchorThreadKey !== null && toRemove.has(state.anchorThreadKey) ? null - : state.anchorThreadId; - return { selectedThreadIds: next, anchorThreadId: newAnchor }; + : state.anchorThreadKey; + return { selectedThreadKeys: next, anchorThreadKey: newAnchor }; }); }, - hasSelection: () => get().selectedThreadIds.size > 0, + hasSelection: () => get().selectedThreadKeys.size > 0, })); diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 972cf42bab..a544975731 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,7 +1,9 @@ import type { + EnvironmentId, ModelSelection, OrchestrationLatestTurn, OrchestrationProposedPlanId, + RepositoryIdentity, OrchestrationSessionStatus, OrchestrationThreadActivity, ProjectScript as ContractProjectScript, @@ -80,8 +82,10 @@ export interface TurnDiffSummary { export interface Project { id: ProjectId; + environmentId: EnvironmentId; name: string; cwd: string; + repositoryIdentity?: RepositoryIdentity | null; defaultModelSelection: ModelSelection | null; createdAt?: string | undefined; updatedAt?: string | undefined; @@ -90,6 +94,7 @@ export interface Project { export interface Thread { id: ThreadId; + environmentId: EnvironmentId; codexThreadId: string | null; projectId: ProjectId; title: string; @@ -113,6 +118,7 @@ export interface Thread { export interface ThreadShell { id: ThreadId; + environmentId: EnvironmentId; codexThreadId: string | null; projectId: ProjectId; title: string; @@ -134,6 +140,7 @@ export interface ThreadTurnState { export interface SidebarThreadSummary { id: ThreadId; + environmentId: EnvironmentId; projectId: ProjectId; title: string; interactionMode: ProviderInteractionMode; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index b0b19f763a..950a7e11ff 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -74,9 +74,9 @@ describe("uiStateStore pure functions", () => { }); const next = syncProjects(initialState, [ - { id: project1, cwd: "/tmp/project-1" }, - { id: project2, cwd: "/tmp/project-2" }, - { id: project3, cwd: "/tmp/project-3" }, + { key: project1, cwd: "/tmp/project-1" }, + { key: project2, cwd: "/tmp/project-2" }, + { key: project3, cwd: "/tmp/project-3" }, ]); expect(next.projectOrder).toEqual([project2, project1, project3]); @@ -96,14 +96,14 @@ describe("uiStateStore pure functions", () => { projectOrder: [oldProject2, oldProject1], }), [ - { id: oldProject1, cwd: "/tmp/project-1" }, - { id: oldProject2, cwd: "/tmp/project-2" }, + { key: oldProject1, cwd: "/tmp/project-1" }, + { key: oldProject2, cwd: "/tmp/project-2" }, ], ); const next = syncProjects(initialState, [ - { id: oldProject1, cwd: "/tmp/project-1" }, - { id: recreatedProject2, cwd: "/tmp/project-2" }, + { key: oldProject1, cwd: "/tmp/project-1" }, + { key: recreatedProject2, cwd: "/tmp/project-2" }, ]); expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]); @@ -119,10 +119,10 @@ describe("uiStateStore pure functions", () => { }, projectOrder: [project1], }), - [{ id: project1, cwd: "/tmp/project-1" }], + [{ key: project1, cwd: "/tmp/project-1" }], ); - const next = syncProjects(initialState, [{ id: project1, cwd: "/tmp/project-1-renamed" }]); + const next = syncProjects(initialState, [{ key: project1, cwd: "/tmp/project-1-renamed" }]); expect(next).not.toBe(initialState); expect(next.projectOrder).toEqual([project1]); @@ -139,7 +139,7 @@ describe("uiStateStore pure functions", () => { }, }); - const next = syncThreads(initialState, [{ id: thread1 }]); + const next = syncThreads(initialState, [{ key: thread1 }]); expect(next.threadLastVisitedAtById).toEqual({ [thread1]: "2026-02-25T12:35:00.000Z", @@ -152,7 +152,7 @@ describe("uiStateStore pure functions", () => { const next = syncThreads(initialState, [ { - id: thread1, + key: thread1, seedVisitedAt: "2026-02-25T12:35:00.000Z", }, ]); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 342f2db18f..936afc42f5 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,5 +1,4 @@ import { Debouncer } from "@tanstack/react-pacer"; -import { type ProjectId, type ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; @@ -23,7 +22,7 @@ interface PersistedUiState { export interface UiProjectState { projectExpandedById: Record; - projectOrder: ProjectId[]; + projectOrder: string[]; } export interface UiThreadState { @@ -33,12 +32,12 @@ export interface UiThreadState { export interface UiState extends UiProjectState, UiThreadState {} export interface SyncProjectInput { - id: ProjectId; + key: string; cwd: string; } export interface SyncThreadInput { - id: ThreadId; + key: string; seedVisitedAt?: string | undefined; } @@ -50,7 +49,7 @@ const initialState: UiState = { const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; -const currentProjectCwdById = new Map(); +const currentProjectCwdById = new Map(); let legacyKeysCleanedUp = false; function readPersistedState(): UiState { @@ -100,7 +99,7 @@ function persistState(state: UiState): void { const expandedProjectCwds = Object.entries(state.projectExpandedById) .filter(([, expanded]) => expanded) .flatMap(([projectId]) => { - const cwd = currentProjectCwdById.get(projectId as ProjectId); + const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; }); const projectOrderCwds = state.projectOrder.flatMap((projectId) => { @@ -141,7 +140,7 @@ function recordsEqual(left: Record, right: Record): boo return true; } -function projectOrdersEqual(left: readonly ProjectId[], right: readonly ProjectId[]): boolean { +function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean { return ( left.length === right.length && left.every((projectId, index) => projectId === right[index]) ); @@ -154,11 +153,11 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput ); currentProjectCwdById.clear(); for (const project of projects) { - currentProjectCwdById.set(project.id, project.cwd); + currentProjectCwdById.set(project.key, project.cwd); } const cwdMappingChanged = previousProjectCwdById.size !== currentProjectCwdById.size || - projects.some((project) => previousProjectCwdById.get(project.id) !== project.cwd); + projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd); const nextExpandedById: Record = {}; const previousExpandedById = state.projectExpandedById; @@ -168,14 +167,14 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput const mappedProjects = projects.map((project, index) => { const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd); const expanded = - previousExpandedById[project.id] ?? + previousExpandedById[project.key] ?? (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ?? (persistedExpandedProjectCwds.size > 0 ? persistedExpandedProjectCwds.has(project.cwd) : true); - nextExpandedById[project.id] = expanded; + nextExpandedById[project.key] = expanded; return { - id: project.id, + id: project.key, cwd: project.cwd, incomingIndex: index, }; @@ -187,8 +186,8 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput const nextProjectIdByCwd = new Map( mappedProjects.map((project) => [project.cwd, project.id] as const), ); - const usedProjectIds = new Set(); - const orderedProjectIds: ProjectId[] = []; + const usedProjectIds = new Set(); + const orderedProjectIds: string[] = []; for (const projectId of state.projectOrder) { const matchedProjectId = @@ -246,19 +245,19 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput } export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState { - const retainedThreadIds = new Set(threads.map((thread) => thread.id)); + const retainedThreadIds = new Set(threads.map((thread) => thread.key)); const nextThreadLastVisitedAtById = Object.fromEntries( Object.entries(state.threadLastVisitedAtById).filter(([threadId]) => - retainedThreadIds.has(threadId as ThreadId), + retainedThreadIds.has(threadId), ), ); for (const thread of threads) { if ( - nextThreadLastVisitedAtById[thread.id] === undefined && + nextThreadLastVisitedAtById[thread.key] === undefined && thread.seedVisitedAt !== undefined && thread.seedVisitedAt.length > 0 ) { - nextThreadLastVisitedAtById[thread.id] = thread.seedVisitedAt; + nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt; } } if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) { @@ -270,7 +269,7 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) }; } -export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt?: string): UiState { +export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState { const at = visitedAt ?? new Date().toISOString(); const visitedAtMs = Date.parse(at); const previousVisitedAt = state.threadLastVisitedAtById[threadId]; @@ -293,7 +292,7 @@ export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt? export function markThreadUnread( state: UiState, - threadId: ThreadId, + threadId: string, latestTurnCompletedAt: string | null | undefined, ): UiState { if (!latestTurnCompletedAt) { @@ -316,7 +315,7 @@ export function markThreadUnread( }; } -export function clearThreadUi(state: UiState, threadId: ThreadId): UiState { +export function clearThreadUi(state: UiState, threadId: string): UiState { if (!(threadId in state.threadLastVisitedAtById)) { return state; } @@ -328,7 +327,7 @@ export function clearThreadUi(state: UiState, threadId: ThreadId): UiState { }; } -export function toggleProject(state: UiState, projectId: ProjectId): UiState { +export function toggleProject(state: UiState, projectId: string): UiState { const expanded = state.projectExpandedById[projectId] ?? true; return { ...state, @@ -339,11 +338,7 @@ export function toggleProject(state: UiState, projectId: ProjectId): UiState { }; } -export function setProjectExpanded( - state: UiState, - projectId: ProjectId, - expanded: boolean, -): UiState { +export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState { if ((state.projectExpandedById[projectId] ?? true) === expanded) { return state; } @@ -358,8 +353,8 @@ export function setProjectExpanded( export function reorderProjects( state: UiState, - draggedProjectId: ProjectId, - targetProjectId: ProjectId, + draggedProjectId: string, + targetProjectId: string, ): UiState { if (draggedProjectId === targetProjectId) { return state; @@ -384,12 +379,12 @@ export function reorderProjects( interface UiStateStore extends UiState { syncProjects: (projects: readonly SyncProjectInput[]) => void; syncThreads: (threads: readonly SyncThreadInput[]) => void; - markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; - markThreadUnread: (threadId: ThreadId, latestTurnCompletedAt: string | null | undefined) => void; - clearThreadUi: (threadId: ThreadId) => void; - toggleProject: (projectId: ProjectId) => void; - setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void; - reorderProjects: (draggedProjectId: ProjectId, targetProjectId: ProjectId) => void; + markThreadVisited: (threadId: string, visitedAt?: string) => void; + markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; + clearThreadUi: (threadId: string) => void; + toggleProject: (projectId: string) => void; + setProjectExpanded: (projectId: string, expanded: boolean) => void; + reorderProjects: (draggedProjectId: string, targetProjectId: string) => void; } export const useUiStateStore = create((set) => ({ diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index c3a23ad405..1d7d41db1b 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,6 +1,6 @@ /// -import type { NativeApi, DesktopBridge } from "@t3tools/contracts"; +import type { DesktopBridge, LocalApi } from "@t3tools/contracts"; interface ImportMetaEnv { readonly APP_VERSION: string; @@ -12,7 +12,7 @@ interface ImportMeta { declare global { interface Window { - nativeApi?: NativeApi; + nativeApi?: LocalApi; desktopBridge?: DesktopBridge; } } diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 723661ccbb..1af904495c 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -1,12 +1,15 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts deleted file mode 100644 index 3cfb976e09..0000000000 --- a/apps/web/src/wsNativeApi.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts"; - -import { resetGitStatusStateForTests } from "./lib/gitStatusState"; -import { showContextMenuFallback } from "./contextMenuFallback"; -import { __resetWsRpcAtomClientForTests } from "./rpc/client"; -import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; -import { resetServerStateForTests } from "./rpc/serverState"; -import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; -import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient"; - -let instance: { api: NativeApi } | null = null; - -export async function __resetWsNativeApiForTests() { - instance = null; - await __resetWsRpcAtomClientForTests(); - await __resetWsRpcClientForTests(); - resetGitStatusStateForTests(); - resetRequestLatencyStateForTests(); - resetServerStateForTests(); - resetWsConnectionStateForTests(); -} - -export function createWsNativeApi(): NativeApi { - if (instance) { - return instance.api; - } - - const rpcClient = getWsRpcClient(); - - const api: NativeApi = { - dialogs: { - pickFolder: async () => { - if (!window.desktopBridge) return null; - return window.desktopBridge.pickFolder(); - }, - confirm: async (message) => { - if (window.desktopBridge) { - return window.desktopBridge.confirm(message); - } - return window.confirm(message); - }, - }, - terminal: { - open: (input) => rpcClient.terminal.open(input as never), - write: (input) => rpcClient.terminal.write(input as never), - resize: (input) => rpcClient.terminal.resize(input as never), - clear: (input) => rpcClient.terminal.clear(input as never), - restart: (input) => rpcClient.terminal.restart(input as never), - close: (input) => rpcClient.terminal.close(input as never), - onEvent: (callback) => rpcClient.terminal.onEvent(callback), - }, - projects: { - searchEntries: rpcClient.projects.searchEntries, - writeFile: rpcClient.projects.writeFile, - }, - shell: { - openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), - openExternal: async (url) => { - if (window.desktopBridge) { - const opened = await window.desktopBridge.openExternal(url); - if (!opened) { - throw new Error("Unable to open link."); - } - return; - } - - window.open(url, "_blank", "noopener,noreferrer"); - }, - }, - git: { - pull: rpcClient.git.pull, - refreshStatus: rpcClient.git.refreshStatus, - onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), - listBranches: rpcClient.git.listBranches, - createWorktree: rpcClient.git.createWorktree, - removeWorktree: rpcClient.git.removeWorktree, - createBranch: rpcClient.git.createBranch, - checkout: rpcClient.git.checkout, - init: rpcClient.git.init, - resolvePullRequest: rpcClient.git.resolvePullRequest, - preparePullRequestThread: rpcClient.git.preparePullRequestThread, - }, - contextMenu: { - show: async ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ): Promise => { - if (window.desktopBridge) { - return window.desktopBridge.showContextMenu(items, position) as Promise; - } - return showContextMenuFallback(items, position); - }, - }, - server: { - getConfig: rpcClient.server.getConfig, - refreshProviders: rpcClient.server.refreshProviders, - upsertKeybinding: rpcClient.server.upsertKeybinding, - getSettings: rpcClient.server.getSettings, - updateSettings: rpcClient.server.updateSettings, - }, - orchestration: { - getSnapshot: rpcClient.orchestration.getSnapshot, - dispatchCommand: rpcClient.orchestration.dispatchCommand, - getTurnDiff: rpcClient.orchestration.getTurnDiff, - getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, - replayEvents: (fromSequenceExclusive) => - rpcClient.orchestration - .replayEvents({ fromSequenceExclusive }) - .then((events) => [...events]), - onDomainEvent: (callback, options) => - rpcClient.orchestration.onDomainEvent(callback, options), - }, - }; - - instance = { api }; - return api; -} diff --git a/apps/web/src/wsRpcClient.test.ts b/apps/web/src/wsRpcClient.test.ts index 36467eed9a..8d1cf6849f 100644 --- a/apps/web/src/wsRpcClient.test.ts +++ b/apps/web/src/wsRpcClient.test.ts @@ -3,9 +3,25 @@ import type { GitStatusRemoteResult, GitStatusStreamEvent, } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { createWsRpcClient } from "./wsRpcClient"; +vi.mock("./wsTransport", () => ({ + WsTransport: class WsTransport { + dispose = vi.fn(async () => undefined); + reconnect = vi.fn(async () => undefined); + request = vi.fn(); + requestStream = vi.fn(); + subscribe = vi.fn(() => () => undefined); + }, +})); + +import { + __resetWsRpcClientForTests, + createWsRpcClient, + ensureWsRpcClientEntryForKnownEnvironment, + readWsRpcClientEntryForEnvironment, +} from "./wsRpcClient"; import { type WsTransport } from "./wsTransport"; const baseLocalStatus: GitStatusLocalResult = { @@ -25,6 +41,10 @@ const baseRemoteStatus: GitStatusRemoteResult = { }; describe("wsRpcClient", () => { + afterEach(async () => { + await __resetWsRpcClientForTests(); + }); + it("reduces git status stream events into flat status snapshots", () => { const subscribe = vi.fn((_connect: unknown, listener: (value: TValue) => void) => { for (const event of [ @@ -91,4 +111,20 @@ describe("wsRpcClient", () => { ], ]); }); + + it("does not fall back to the only registered client for an unbound environment", () => { + ensureWsRpcClientEntryForKnownEnvironment({ + id: "known-env-a", + label: "Environment A", + source: "manual", + target: { + type: "ws", + wsUrl: "ws://localhost:3000", + }, + }); + + expect( + readWsRpcClientEntryForEnvironment(EnvironmentId.makeUnsafe("environment-b")), + ).toBeNull(); + }); }); diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 997b83d2d7..7251b91fab 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -4,14 +4,20 @@ import { type GitRunStackedActionResult, type GitStatusResult, type GitStatusStreamEvent, - type NativeApi, + type LocalApi, ORCHESTRATION_WS_METHODS, + type EnvironmentId, type ServerSettingsPatch, WS_METHODS, } from "@t3tools/contracts"; +import { getKnownEnvironmentBaseUrl, type KnownEnvironment } from "@t3tools/client-runtime"; import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; import { Effect, Stream } from "effect"; +import { + getPrimaryKnownEnvironment, + resolvePrimaryEnvironmentBootstrapUrl, +} from "./environmentBootstrap"; import { type WsRpcProtocolClient } from "./rpc/protocol"; import { resetWsReconnectBackoff } from "./rpc/wsConnectionState"; import { WsTransport } from "./wsTransport"; @@ -61,9 +67,9 @@ export interface WsRpcClient { }; readonly shell: { readonly openInEditor: (input: { - readonly cwd: Parameters[0]; - readonly editor: Parameters[1]; - }) => ReturnType; + readonly cwd: Parameters[0]; + readonly editor: Parameters[1]; + }) => ReturnType; }; readonly git: { readonly pull: RpcUnaryMethod; @@ -109,22 +115,157 @@ export interface WsRpcClient { }; } -let sharedWsRpcClient: WsRpcClient | null = null; +export interface WsRpcClientEntry { + readonly key: string; + readonly knownEnvironment: KnownEnvironment; + readonly client: WsRpcClient; + readonly environmentId: EnvironmentId | null; +} + +type MutableWsRpcClientEntry = { + key: string; + knownEnvironment: KnownEnvironment; + client: WsRpcClient; + environmentId: EnvironmentId | null; +}; + +const wsRpcClientEntriesByKey = new Map(); +const wsRpcClientKeyByEnvironmentId = new Map(); +const wsRpcClientRegistryListeners = new Set<() => void>(); + +function emitWsRpcClientRegistryChange() { + for (const listener of wsRpcClientRegistryListeners) { + listener(); + } +} + +function toReadonlyEntry(entry: MutableWsRpcClientEntry): WsRpcClientEntry { + return entry; +} + +function createWsRpcClientEntry(knownEnvironment: KnownEnvironment): MutableWsRpcClientEntry { + const baseUrl = getKnownEnvironmentBaseUrl(knownEnvironment); + if (!baseUrl) { + throw new Error(`Unable to resolve websocket bootstrap URL for ${knownEnvironment.label}.`); + } + + return { + key: knownEnvironment.id, + knownEnvironment, + client: createWsRpcClient(new WsTransport(baseUrl)), + environmentId: knownEnvironment.environmentId ?? null, + }; +} + +export function subscribeWsRpcClientRegistry(listener: () => void): () => void { + wsRpcClientRegistryListeners.add(listener); + return () => { + wsRpcClientRegistryListeners.delete(listener); + }; +} + +export function listWsRpcClientEntries(): ReadonlyArray { + return [...wsRpcClientEntriesByKey.values()].map(toReadonlyEntry); +} + +export function ensureWsRpcClientEntryForKnownEnvironment( + knownEnvironment: KnownEnvironment, +): WsRpcClientEntry { + const existingEntry = wsRpcClientEntriesByKey.get(knownEnvironment.id); + if (existingEntry) { + return toReadonlyEntry(existingEntry); + } + + const entry = createWsRpcClientEntry(knownEnvironment); + wsRpcClientEntriesByKey.set(entry.key, entry); + if (entry.environmentId) { + wsRpcClientKeyByEnvironmentId.set(entry.environmentId, entry.key); + } + emitWsRpcClientRegistryChange(); + return toReadonlyEntry(entry); +} + +export function getPrimaryWsRpcClientEntry(): WsRpcClientEntry { + const primaryKnownEnvironment = getPrimaryKnownEnvironment(); + if (!primaryKnownEnvironment) { + throw new Error("Unable to resolve the primary websocket environment."); + } + + return ensureWsRpcClientEntryForKnownEnvironment(primaryKnownEnvironment); +} export function getWsRpcClient(): WsRpcClient { - if (sharedWsRpcClient) { - return sharedWsRpcClient; + return getPrimaryWsRpcClientEntry().client; +} + +export function bindWsRpcClientEntryEnvironment( + clientKey: string, + environmentId: EnvironmentId, +): void { + const entry = wsRpcClientEntriesByKey.get(clientKey); + if (!entry) { + throw new Error(`No websocket client registered for key ${clientKey}.`); + } + + const previousBoundEnvironmentId = entry.environmentId; + const previousKeyForEnvironment = wsRpcClientKeyByEnvironmentId.get(environmentId); + + if (previousBoundEnvironmentId === environmentId && previousKeyForEnvironment === clientKey) { + return; + } + + if (previousBoundEnvironmentId) { + wsRpcClientKeyByEnvironmentId.delete(previousBoundEnvironmentId); + } + + if (previousKeyForEnvironment && previousKeyForEnvironment !== clientKey) { + const previousEntry = wsRpcClientEntriesByKey.get(previousKeyForEnvironment); + if (previousEntry) { + previousEntry.environmentId = null; + } + } + + entry.environmentId = environmentId; + wsRpcClientKeyByEnvironmentId.set(environmentId, clientKey); + emitWsRpcClientRegistryChange(); +} + +export function bindPrimaryWsRpcClientEnvironment(environmentId: EnvironmentId): void { + bindWsRpcClientEntryEnvironment(getPrimaryWsRpcClientEntry().key, environmentId); +} + +export function readWsRpcClientEntryForEnvironment( + environmentId: EnvironmentId, +): WsRpcClientEntry | null { + const clientKey = wsRpcClientKeyByEnvironmentId.get(environmentId); + if (clientKey) { + const entry = wsRpcClientEntriesByKey.get(clientKey); + return entry ? toReadonlyEntry(entry) : null; + } + + return null; +} + +export function getWsRpcClientForEnvironment(environmentId: EnvironmentId): WsRpcClient { + const entry = readWsRpcClientEntryForEnvironment(environmentId); + if (!entry) { + throw new Error(`No websocket client registered for environment ${environmentId}.`); } - sharedWsRpcClient = createWsRpcClient(); - return sharedWsRpcClient; + return entry.client; } export async function __resetWsRpcClientForTests() { - await sharedWsRpcClient?.dispose(); - sharedWsRpcClient = null; + for (const entry of wsRpcClientEntriesByKey.values()) { + await entry.client.dispose(); + } + wsRpcClientEntriesByKey.clear(); + wsRpcClientKeyByEnvironmentId.clear(); + wsRpcClientRegistryListeners.clear(); } -export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { +export function createWsRpcClient( + transport = new WsTransport(resolvePrimaryEnvironmentBootstrapUrl()), +): WsRpcClient { return { dispose: () => transport.dispose(), reconnect: async () => { diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index da5404b239..58453d9913 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -436,6 +436,13 @@ describe("WsTransport", () => { sequence: 1, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/workspace", projectName: "workspace", }, @@ -489,6 +496,13 @@ describe("WsTransport", () => { sequence: 1, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/one", projectName: "one", }, @@ -532,6 +546,13 @@ describe("WsTransport", () => { sequence: 2, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/two", projectName: "two", }, diff --git a/bun.lock b/bun.lock index af243cf4eb..74c5badbe6 100644 --- a/bun.lock +++ b/bun.lock @@ -82,6 +82,7 @@ "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", @@ -121,6 +122,18 @@ "vitest-browser-react": "^2.0.5", }, }, + "packages/client-runtime": { + "name": "@t3tools/client-runtime", + "version": "0.0.0-alpha.1", + "dependencies": { + "@t3tools/contracts": "workspace:*", + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, "packages/contracts": { "name": "@t3tools/contracts", "version": "0.0.15", @@ -659,6 +672,8 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@t3tools/client-runtime": ["@t3tools/client-runtime@workspace:packages/client-runtime"], + "@t3tools/contracts": ["@t3tools/contracts@workspace:packages/contracts"], "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"], diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json new file mode 100644 index 0000000000..bfe2d7828e --- /dev/null +++ b/packages/client-runtime/package.json @@ -0,0 +1,25 @@ +{ + "name": "@t3tools/client-runtime", + "version": "0.0.0-alpha.1", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "prepare": "effect-language-service patch", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*" + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts new file mode 100644 index 0000000000..5dd6b9afa5 --- /dev/null +++ b/packages/client-runtime/src/index.ts @@ -0,0 +1,2 @@ +export * from "./knownEnvironment"; +export * from "./scoped"; diff --git a/packages/client-runtime/src/knownEnvironment.test.ts b/packages/client-runtime/src/knownEnvironment.test.ts new file mode 100644 index 0000000000..0856a253a2 --- /dev/null +++ b/packages/client-runtime/src/knownEnvironment.test.ts @@ -0,0 +1,63 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { createKnownEnvironmentFromWsUrl } from "./knownEnvironment"; +import { + parseScopedProjectKey, + parseScopedThreadKey, + scopedProjectKey, + scopedRefKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "./scoped"; + +describe("known environment bootstrap helpers", () => { + it("creates known environments from explicit ws urls", () => { + expect( + createKnownEnvironmentFromWsUrl({ + label: "Remote environment", + wsUrl: "wss://remote.example.com/ws", + }), + ).toEqual({ + id: "ws:Remote environment", + label: "Remote environment", + source: "manual", + target: { + type: "ws", + wsUrl: "wss://remote.example.com/ws", + }, + }); + }); +}); + +describe("scoped refs", () => { + const environmentId = EnvironmentId.makeUnsafe("environment-test"); + const projectRef = scopeProjectRef(environmentId, ProjectId.makeUnsafe("project-1")); + const threadRef = scopeThreadRef(environmentId, ThreadId.makeUnsafe("thread-1")); + + it("builds stable scoped project and thread keys", () => { + expect(scopedRefKey(projectRef)).toBe("environment-test:project-1"); + expect(scopedRefKey(threadRef)).toBe("environment-test:thread-1"); + expect(scopedProjectKey(projectRef)).toBe("environment-test:project-1"); + expect(scopedThreadKey(threadRef)).toBe("environment-test:thread-1"); + }); + + it("returns typed scoped refs", () => { + expect(projectRef).toEqual({ + environmentId, + projectId: ProjectId.makeUnsafe("project-1"), + }); + expect(threadRef).toEqual({ + environmentId, + threadId: ThreadId.makeUnsafe("thread-1"), + }); + }); + + it("parses scoped project and thread keys back into refs", () => { + expect(parseScopedProjectKey("environment-test:project-1")).toEqual(projectRef); + expect(parseScopedThreadKey("environment-test:thread-1")).toEqual(threadRef); + expect(parseScopedProjectKey("bad-key")).toBeNull(); + expect(parseScopedThreadKey("bad-key")).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/knownEnvironment.ts new file mode 100644 index 0000000000..3a5e0d0e7d --- /dev/null +++ b/packages/client-runtime/src/knownEnvironment.ts @@ -0,0 +1,39 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface KnownEnvironmentConnectionTarget { + readonly type: "ws"; + readonly wsUrl: string; +} + +export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin"; + +export interface KnownEnvironment { + readonly id: string; + readonly label: string; + readonly source: KnownEnvironmentSource; + readonly environmentId?: EnvironmentId; + readonly target: KnownEnvironmentConnectionTarget; +} + +export function createKnownEnvironmentFromWsUrl(input: { + readonly id?: string; + readonly label: string; + readonly source?: KnownEnvironmentSource; + readonly wsUrl: string; +}): KnownEnvironment { + return { + id: input.id ?? `ws:${input.label}`, + label: input.label, + source: input.source ?? "manual", + target: { + type: "ws", + wsUrl: input.wsUrl, + }, + }; +} + +export function getKnownEnvironmentBaseUrl( + environment: KnownEnvironment | null | undefined, +): string | null { + return environment?.target.wsUrl ?? null; +} diff --git a/packages/client-runtime/src/scoped.ts b/packages/client-runtime/src/scoped.ts new file mode 100644 index 0000000000..c729f34d22 --- /dev/null +++ b/packages/client-runtime/src/scoped.ts @@ -0,0 +1,64 @@ +import type { + EnvironmentId, + ProjectId, + ScopedProjectRef, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; + +export function scopeProjectRef( + environmentId: EnvironmentId, + projectId: ProjectId, +): ScopedProjectRef { + return { environmentId, projectId }; +} + +export function scopeThreadRef(environmentId: EnvironmentId, threadId: ThreadId): ScopedThreadRef { + return { environmentId, threadId }; +} + +export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { + const localId = "projectId" in ref ? ref.projectId : ref.threadId; + return `${ref.environmentId}:${localId}`; +} + +export function scopedProjectKey(ref: ScopedProjectRef): string { + return scopedRefKey(ref); +} + +export function scopedThreadKey(ref: ScopedThreadRef): string { + return scopedRefKey(ref); +} + +function parseScopedKey(key: string): { environmentId: EnvironmentId; localId: string } | null { + const separatorIndex = key.indexOf(":"); + if (separatorIndex <= 0 || separatorIndex >= key.length - 1) { + return null; + } + return { + environmentId: key.slice(0, separatorIndex) as EnvironmentId, + localId: key.slice(separatorIndex + 1), + }; +} + +export function parseScopedProjectKey(key: string): ScopedProjectRef | null { + const parsed = parseScopedKey(key); + if (!parsed) { + return null; + } + return { + environmentId: parsed.environmentId, + projectId: parsed.localId as ProjectId, + }; +} + +export function parseScopedThreadKey(key: string): ScopedThreadRef | null { + const parsed = parseScopedKey(key); + if (!parsed) { + return null; + } + return { + environmentId: parsed.environmentId, + threadId: parsed.localId as ThreadId, + }; +} diff --git a/packages/client-runtime/tsconfig.json b/packages/client-runtime/tsconfig.json new file mode 100644 index 0000000000..564a599005 --- /dev/null +++ b/packages/client-runtime/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 24962aed69..5a199e9a67 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -19,6 +19,8 @@ export const ThreadId = makeEntityId("ThreadId"); export type ThreadId = typeof ThreadId.Type; export const ProjectId = makeEntityId("ProjectId"); export type ProjectId = typeof ProjectId.Type; +export const EnvironmentId = makeEntityId("EnvironmentId"); +export type EnvironmentId = typeof EnvironmentId.Type; export const CommandId = makeEntityId("CommandId"); export type CommandId = typeof CommandId.Type; export const EventId = makeEntityId("EventId"); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts new file mode 100644 index 0000000000..9e97be83ea --- /dev/null +++ b/packages/contracts/src/environment.ts @@ -0,0 +1,77 @@ +import { Schema } from "effect"; + +import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; + +export const ExecutionEnvironmentPlatformOs = Schema.Literals([ + "darwin", + "linux", + "windows", + "unknown", +]); +export type ExecutionEnvironmentPlatformOs = typeof ExecutionEnvironmentPlatformOs.Type; + +export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]); +export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; + +export const ExecutionEnvironmentPlatform = Schema.Struct({ + os: ExecutionEnvironmentPlatformOs, + arch: ExecutionEnvironmentPlatformArch, +}); +export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; + +export const ExecutionEnvironmentCapabilities = Schema.Struct({ + repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), +}); +export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; + +export const ExecutionEnvironmentDescriptor = Schema.Struct({ + environmentId: EnvironmentId, + label: TrimmedNonEmptyString, + platform: ExecutionEnvironmentPlatform, + serverVersion: TrimmedNonEmptyString, + capabilities: ExecutionEnvironmentCapabilities, +}); +export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; + +export const EnvironmentConnectionState = Schema.Literals([ + "connecting", + "connected", + "disconnected", + "error", +]); +export type EnvironmentConnectionState = typeof EnvironmentConnectionState.Type; + +export const RepositoryIdentityLocator = Schema.Struct({ + source: Schema.Literal("git-remote"), + remoteName: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, +}); +export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; + +export const RepositoryIdentity = Schema.Struct({ + canonicalKey: TrimmedNonEmptyString, + locator: RepositoryIdentityLocator, + displayName: Schema.optionalKey(TrimmedNonEmptyString), + provider: Schema.optionalKey(TrimmedNonEmptyString), + owner: Schema.optionalKey(TrimmedNonEmptyString), + name: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type RepositoryIdentity = typeof RepositoryIdentity.Type; + +export const ScopedProjectRef = Schema.Struct({ + environmentId: EnvironmentId, + projectId: ProjectId, +}); +export type ScopedProjectRef = typeof ScopedProjectRef.Type; + +export const ScopedThreadRef = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type ScopedThreadRef = typeof ScopedThreadRef.Type; + +export const ScopedThreadSessionRef = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type ScopedThreadSessionRef = typeof ScopedThreadSessionRef.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c60856bbe5..d2f84eda9d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas"; +export * from "./environment"; export * from "./ipc"; export * from "./terminal"; export * from "./provider"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 630ccd8249..1a08d5208c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -105,8 +105,14 @@ export interface DesktopUpdateCheckResult { state: DesktopUpdateState; } +export interface DesktopEnvironmentBootstrap { + label: string; + wsUrl: string | null; +} + export interface DesktopBridge { getWsUrl: () => string | null; + getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; @@ -123,11 +129,50 @@ export interface DesktopBridge { onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; } -export interface NativeApi { +/** + * APIs bound to the local app shell, not to any particular backend environment. + * + * These capabilities describe the desktop/browser host that the user is + * currently running: dialogs, editor/external-link opening, context menus, and + * app-level settings/config access. They must not be used as a proxy for + * "whatever environment the user is targeting", because in a multi-environment + * world the local shell and a selected backend environment are distinct + * concepts. + */ +export interface LocalApi { dialogs: { pickFolder: () => Promise; confirm: (message: string) => Promise; }; + shell: { + openInEditor: (cwd: string, editor: EditorId) => Promise; + openExternal: (url: string) => Promise; + }; + contextMenu: { + show: ( + items: readonly ContextMenuItem[], + position?: { x: number; y: number }, + ) => Promise; + }; + server: { + getConfig: () => Promise; + refreshProviders: () => Promise; + upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + getSettings: () => Promise; + updateSettings: (patch: ServerSettingsPatch) => Promise; + }; +} + +/** + * APIs bound to a specific backend environment connection. + * + * These operations must always be routed with explicit environment context. + * They represent remote stateful capabilities such as orchestration, terminal, + * project, and git operations. In multi-environment mode, each environment gets + * its own instance of this surface, and callers should resolve it by + * `environmentId` rather than reaching through the local desktop bridge. + */ +export interface EnvironmentApi { terminal: { open: (input: typeof TerminalOpenInput.Encoded) => Promise; write: (input: typeof TerminalWriteInput.Encoded) => Promise; @@ -141,12 +186,7 @@ export interface NativeApi { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; - shell: { - openInEditor: (cwd: string, editor: EditorId) => Promise; - openExternal: (url: string) => Promise; - }; git: { - // Existing branch/worktree API listBranches: (input: GitListBranchesInput) => Promise; createWorktree: (input: GitCreateWorktreeInput) => Promise; removeWorktree: (input: GitRemoveWorktreeInput) => Promise; @@ -157,7 +197,6 @@ export interface NativeApi { preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Promise; - // Stacked action API pull: (input: GitPullInput) => Promise; refreshStatus: (input: GitStatusInput) => Promise; onStatus: ( @@ -168,19 +207,6 @@ export interface NativeApi { }, ) => () => void; }; - contextMenu: { - show: ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ) => Promise; - }; - server: { - getConfig: () => Promise; - refreshProviders: () => Promise; - upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; - getSettings: () => Promise; - updateSettings: (patch: ServerSettingsPatch) => Promise; - }; orchestration: { getSnapshot: () => Promise; dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6c7f073612..e6e4a52106 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,6 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; import { ClaudeModelOptions, CodexModelOptions } from "./model"; +import { RepositoryIdentity } from "./environment"; import { ApprovalRequestId, CheckpointRef, @@ -141,6 +142,7 @@ export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -647,6 +649,7 @@ export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -657,6 +660,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 776a0a89e9..9227f4d8c9 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import { ExecutionEnvironmentDescriptor } from "./environment"; import { IsoDateTime, NonNegativeInt, @@ -83,6 +84,7 @@ export const ServerObservability = Schema.Struct({ export type ServerObservability = typeof ServerObservability.Type; export const ServerConfig = Schema.Struct({ + environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, @@ -167,10 +169,12 @@ export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; export const ServerLifecycleReadyPayload = Schema.Struct({ at: IsoDateTime, + environment: ExecutionEnvironmentDescriptor, }); export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type; export const ServerLifecycleWelcomePayload = Schema.Struct({ + environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, projectName: TrimmedNonEmptyString, bootstrapProjectId: Schema.optional(ProjectId), diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 7beb7a75de..dac644e83b 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -1,7 +1,54 @@ import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { applyGitStatusStreamEvent } from "./git"; +import { + applyGitStatusStreamEvent, + normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, +} from "./git"; + +describe("normalizeGitRemoteUrl", () => { + it("canonicalizes equivalent GitHub remotes across protocol variants", () => { + expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe( + "github.com/t3tools/t3code", + ); + }); + + it("preserves nested group paths for providers like GitLab", () => { + expect(normalizeGitRemoteUrl("git@gitlab.com:T3Tools/platform/T3Code.git")).toBe( + "gitlab.com/t3tools/platform/t3code", + ); + expect(normalizeGitRemoteUrl("https://gitlab.com/T3Tools/platform/T3Code.git")).toBe( + "gitlab.com/t3tools/platform/t3code", + ); + }); + + it("drops explicit ports from URL-shaped remotes", () => { + expect(normalizeGitRemoteUrl("https://gitlab.company.com:8443/team/project.git")).toBe( + "gitlab.company.com/team/project", + ); + expect(normalizeGitRemoteUrl("ssh://git@gitlab.company.com:2222/team/project.git")).toBe( + "gitlab.company.com/team/project", + ); + }); +}); + +describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { + it("extracts the owner and repository from common GitHub remote shapes", () => { + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + }); +}); describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 16171315b7..a39c924447 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -80,6 +80,56 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { return branchName.slice(firstSeparatorIndex + 1); } +/** + * Normalize a git remote URL into a stable comparison key. + */ +export function normalizeGitRemoteUrl(value: string): string { + const normalized = value + .trim() + .replace(/\/+$/g, "") + .replace(/\.git$/i, "") + .toLowerCase(); + + if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { + try { + const url = new URL(normalized); + const repositoryPath = url.pathname + .split("/") + .filter((segment) => segment.length > 0) + .join("/"); + if (url.hostname && repositoryPath.includes("/")) { + return `${url.hostname}/${repositoryPath}`; + } + } catch { + return normalized; + } + } + + const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); + if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { + return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; + } + + return normalized; +} + +/** + * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes. + */ +export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { + const trimmed = url?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + + const match = + /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + trimmed, + ); + const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; + return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; +} + function deriveLocalBranchNameCandidatesFromRemoteRef( branchName: string, remoteName?: string, diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index bf9d9f5c6a..98f7da5789 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -13,6 +13,7 @@ const workspaceFiles = [ "apps/desktop/package.json", "apps/web/package.json", "apps/marketing/package.json", + "packages/client-runtime/package.json", "packages/contracts/package.json", "packages/shared/package.json", "scripts/package.json",