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/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..4042eaac71 --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironment.test.ts @@ -0,0 +1,138 @@ +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 { 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 = (baseDir: string): ServerConfigShape => { + const stateDir = nodePath.join(baseDir, "userdata"); + const logsDir = nodePath.join(stateDir, "logs"); + const providerLogsDir = nodePath.join(logsDir, "provider"); + return { + 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, + stateDir, + dbPath: nodePath.join(stateDir, "state.sqlite"), + keybindingsConfigPath: nodePath.join(stateDir, "keybindings.json"), + settingsPath: nodePath.join(stateDir, "settings.json"), + worktreesDir: nodePath.join(baseDir, "worktrees"), + attachmentsDir: nodePath.join(stateDir, "attachments"), + logsDir, + serverLogPath: nodePath.join(logsDir, "server.log"), + serverTracePath: nodePath.join(logsDir, "server.trace.ndjson"), + providerLogsDir, + providerEventLogPath: nodePath.join(providerLogsDir, "events.log"), + terminalLogsDir: nodePath.join(logsDir, "terminals"), + anonymousIdPath: nodePath.join(stateDir, "anonymous-id"), + environmentIdPath: nodePath.join(stateDir, "environment-id"), + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + port: 0, + host: undefined, + authToken: undefined, + staticDir: undefined, + devUrl: undefined, + noBrowser: false, + }; +}; + +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 = 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/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c038bc9d2c..95510f1a4e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -234,6 +234,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..008415f210 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -38,6 +38,8 @@ 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 { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, @@ -163,6 +165,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, @@ -652,10 +656,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); } + 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, @@ -732,19 +748,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), + ), + ), ), ); @@ -816,4 +837,4 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { export const OrchestrationProjectionSnapshotQueryLive = Layer.effect( ProjectionSnapshotQuery, makeProjectionSnapshotQuery, -); +).pipe(Layer.provideMerge(RepositoryIdentityResolverLive)); 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..ba78ac0305 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 { 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,6 +22,7 @@ const envModeItems = [ ] as const; interface BranchToolbarProps { + environmentId: EnvironmentId; threadId: ThreadId; onEnvModeChange: (mode: EnvMode) => void; envLocked: boolean; @@ -28,21 +31,30 @@ interface BranchToolbarProps { } export default function BranchToolbar({ + environmentId, threadId, onEnvModeChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarProps) { - const serverThread = useStore((store) => store.threadShellById[threadId]); - const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null); + const threadRef = scopeThreadRef(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 +68,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,21 +100,24 @@ export default function BranchToolbar({ currentWorktreePath: activeWorktreePath, effectiveEnvMode, }); - setDraftThreadContext(threadId, { + setDraftThreadContext(threadRef, { branch, worktreePath, envMode: nextDraftEnvMode, + projectRef: scopeProjectRef(environmentId, activeProject.id), }); }, [ activeThreadId, + activeProject, serverSession, activeWorktreePath, hasServerThread, setThreadBranchAction, setDraftThreadContext, - threadId, + environmentId, effectiveEnvMode, + threadRef, ], ); @@ -156,6 +171,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 8aa15a06d5..65607c285d 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, @@ -16,6 +17,7 @@ import { OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; @@ -30,11 +32,11 @@ import { 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 { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; @@ -48,8 +50,12 @@ 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 NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -128,6 +134,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: [], @@ -313,6 +326,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, @@ -418,11 +438,28 @@ async function waitForWsClient(): Promise { ); } +function threadRefFor(threadId: ThreadId) { + return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); +} + +function threadKeyFor(threadId: ThreadId): string { + return scopedThreadKey(threadRefFor(threadId)); +} + +function threadIdFromPath(pathname: string): ThreadId { + const segments = pathname.split("/"); + const threadId = segments[segments.length - 1]; + if (!threadId) { + throw new Error(`Expected thread path, received "${pathname}".`); + } + return threadId as 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 }, ); @@ -436,7 +473,9 @@ async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { - expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[threadKeyFor(threadId)]).toBe( + undefined, + ); }, { timeout: 8_000, interval: 16 }, ); @@ -467,9 +506,11 @@ function withProjectScripts( function setDraftThreadWithoutWorktree(): void { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -478,8 +519,8 @@ function setDraftThreadWithoutWorktree(): void { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); } @@ -1048,7 +1089,7 @@ async function mountChatView(options: { const router = getRouter( createMemoryHistory({ - initialEntries: [`/${THREAD_ID}`], + initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], }), ); @@ -1153,42 +1194,27 @@ 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: {}, }); useTerminalStateStore.persist.clearStorage(); useTerminalStateStore.setState({ - terminalStateByThreadId: {}, - terminalLaunchContextByThreadId: {}, + terminalStateByThreadKey: {}, + terminalLaunchContextByThreadKey: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, }); @@ -1429,8 +1455,8 @@ describe("ChatView timeline estimator parity (full app)", () => { } useTerminalStateStore.setState({ - terminalStateByThreadId: { - [THREAD_ID]: { + terminalStateByThreadKey: { + [THREAD_KEY]: { terminalOpen: true, terminalHeight: 280, terminalIds: ["default"], @@ -1440,8 +1466,8 @@ describe("ChatView timeline estimator parity (full app)", () => { activeTerminalGroupId: "group-default", }, }, - terminalLaunchContextByThreadId: { - [THREAD_ID]: { + terminalLaunchContextByThreadKey: { + [THREAD_KEY]: { cwd: "/repo/project", worktreePath: null, }, @@ -1687,9 +1713,11 @@ 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]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1698,8 +1726,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1763,9 +1791,11 @@ 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]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1774,8 +1804,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1826,9 +1856,11 @@ 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]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1837,8 +1869,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1948,12 +1980,14 @@ 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]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1962,8 +1996,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1989,7 +2023,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(); @@ -2048,12 +2082,14 @@ 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]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -2062,8 +2098,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -2092,7 +2128,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(); @@ -2184,7 +2220,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", @@ -2211,21 +2247,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", @@ -2237,7 +2273,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); @@ -2252,7 +2288,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", @@ -2288,7 +2324,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", @@ -2299,7 +2335,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, @@ -2461,7 +2497,7 @@ 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 newThreadId = threadIdFromPath(newThreadPath); // The composer editor should be present for the new draft thread. await waitForComposerEditor(); @@ -2522,9 +2558,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 newThreadId = threadIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + expect(useComposerDraftStore.getState().draftsByThreadKey[newThreadId]).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2575,9 +2611,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 newThreadId = threadIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + expect(useComposerDraftStore.getState().draftsByThreadKey[newThreadId]).toMatchObject({ modelSelectionByProvider: { claudeAgent: { provider: "claudeAgent", @@ -2615,9 +2651,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 newThreadId = threadIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftsByThreadKey[newThreadId]).toBe(undefined); } finally { await mounted.cleanup(); } @@ -2657,9 +2693,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 threadId = threadIdFromPath(threadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + expect(useComposerDraftStore.getState().draftsByThreadKey[threadId]).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2672,7 +2708,7 @@ describe("ChatView timeline estimator parity (full app)", () => { activeProvider: "codex", }); - useComposerDraftStore.getState().setModelSelection(threadId, { + useComposerDraftStore.getState().setModelSelection(threadRefFor(threadId), { provider: "codex", model: "gpt-5.4", options: { @@ -2688,7 +2724,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(useComposerDraftStore.getState().draftsByThreadKey[threadId]).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2795,7 +2831,7 @@ 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 promotedThreadId = threadIdFromPath(promotedThreadPath); await promoteDraftThreadViaDomainEvent(promotedThreadId); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cad565247d..0a134da9c8 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 { @@ -13,6 +14,8 @@ import { waitForStartedServerThread, } from "./ChatView.logic"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ @@ -181,6 +184,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 +212,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 +238,7 @@ function setStoreThreads(threads: ReadonlyArray>) thread.id, { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -301,6 +307,12 @@ function setStoreThreads(threads: ReadonlyArray>) ), sidebarThreadSummaryById: {}, bootstrapComplete: true, + }; + useStore.setState({ + activeEnvironmentId: localEnvironmentId, + environmentStateById: { + [localEnvironmentId]: environmentState, + }, }); } @@ -326,14 +338,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 +390,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 +400,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 +430,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 +467,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 +513,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..2e70e4e28b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,9 +1,15 @@ -import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { + 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 +30,7 @@ export function buildLocalDraftThread( ): Thread { return { id: threadId, + environmentId: draftThread.environmentId, codexThreadId: null, projectId: draftThread.projectId, title: "New thread", @@ -45,12 +52,12 @@ export function buildLocalDraftThread( } 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 +206,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 +232,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 da5c87cbfc..0befac0582 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, @@ -20,6 +21,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 +34,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, @@ -63,8 +73,8 @@ import { setPendingUserInputCustomAnswer, 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,7 +124,6 @@ import { } from "~/projectScripts"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; -import { readNativeApi } from "~/nativeApi"; import { getProviderModelCapabilities, getProviderModels, @@ -123,6 +132,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, @@ -202,6 +213,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 = {}; @@ -209,26 +221,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,7 +379,9 @@ const terminalContextIdListsEqual = ( contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); interface ChatViewProps { + environmentId: EnvironmentId; threadId: ThreadId; + routeKind: "server" | "draft"; } interface TerminalLaunchContext { @@ -357,6 +460,7 @@ function useLocalDispatchState(input: { } interface PersistentThreadTerminalDrawerProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; launchContext: PersistentTerminalLaunchContext | null; @@ -368,6 +472,7 @@ interface PersistentThreadTerminalDrawerProps { } function PersistentThreadTerminalDrawer({ + threadRef, threadId, visible, launchContext, @@ -377,19 +482,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); @@ -435,32 +537,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 = () => @@ -481,10 +583,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( @@ -504,6 +606,7 @@ function PersistentThreadTerminalDrawer({ return (
createThreadSelector(threadId), [threadId])); +export default function ChatView({ environmentId, threadId, routeKind }: ChatViewProps) { + const routeThreadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + 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( @@ -547,7 +661,7 @@ export default function ChatView({ threadId }: ChatViewProps) { select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); - const composerDraft = useComposerThreadDraft(threadId); + const composerDraft = useComposerThreadDraft(routeKind === "server" ? routeThreadRef : threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -590,16 +704,17 @@ 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 getDraftThreadByLogicalProjectKey = useComposerDraftStore( + (store) => store.getDraftThreadByLogicalProjectKey, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const clearProjectDraftThreadId = useComposerDraftStore( - (store) => store.clearProjectDraftThreadId, + const setLogicalProjectDraftThreadId = useComposerDraftStore( + (store) => store.setLogicalProjectDraftThreadId, ); - const draftThread = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] ?? null, + const draftThread = useComposerDraftStore((store) => + routeKind === "server" + ? store.getDraftThreadByRef(routeThreadRef) + : store.getDraftThread(threadId), ); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -688,66 +803,72 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); - const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); + const terminalStateByThreadKey = useTerminalStateStore((state) => state.terminalStateByThreadKey); const terminalState = useMemo( - () => selectThreadTerminalState(terminalStateByThreadId, threadId), - [terminalStateByThreadId, threadId], + () => selectThreadTerminalState(terminalStateByThreadKey, routeThreadRef), + [routeThreadRef, terminalStateByThreadKey], ); - const openTerminalThreadIds = useMemo( + const openTerminalThreadKeys = useMemo( () => - Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) => - nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [], + Object.entries(terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => + nextTerminalState.terminalOpen ? [nextThreadKey] : [], ), - [terminalStateByThreadId], + [terminalStateByThreadKey], ); 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.keys(draftThreadsByThreadKey), + [draftThreadsByThreadKey], ); - const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState([]); + const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); const setPrompt = useCallback( (nextPrompt: string) => { - setComposerDraftPrompt(threadId, nextPrompt); + setComposerDraftPrompt(routeThreadRef, nextPrompt); }, - [setComposerDraftPrompt, threadId], + [routeThreadRef, setComposerDraftPrompt], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { - addComposerDraftImage(threadId, image); + addComposerDraftImage(routeThreadRef, image); }, - [addComposerDraftImage, threadId], + [addComposerDraftImage, routeThreadRef], ); const addComposerImagesToDraft = useCallback( (images: ComposerImageAttachment[]) => { - addComposerDraftImages(threadId, images); + addComposerDraftImages(routeThreadRef, images); }, - [addComposerDraftImages, threadId], + [addComposerDraftImages, routeThreadRef], ); const addComposerTerminalContextsToDraft = useCallback( (contexts: TerminalContextDraft[]) => { - addComposerDraftTerminalContexts(threadId, contexts); + addComposerDraftTerminalContexts(routeThreadRef, contexts); }, - [addComposerDraftTerminalContexts, threadId], + [addComposerDraftTerminalContexts, routeThreadRef], ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { - removeComposerDraftImage(threadId, imageId); + removeComposerDraftImage(routeThreadRef, imageId); }, - [removeComposerDraftImage, threadId], + [removeComposerDraftImage, routeThreadRef], ); const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { @@ -760,7 +881,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); promptRef.current = nextPrompt.prompt; setPrompt(nextPrompt.prompt); - removeComposerDraftTerminalContext(threadId, contextId); + removeComposerDraftTerminalContext(routeThreadRef, contextId); setComposerCursor(nextPrompt.cursor); setComposerTrigger( detectComposerTrigger( @@ -769,13 +890,17 @@ export default function ChatView({ threadId }: ChatViewProps) { ), ); }, - [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], + [composerTerminalContexts, removeComposerDraftTerminalContext, routeThreadRef, 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 : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => draftThread @@ -791,20 +916,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(() => { @@ -824,12 +954,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 && @@ -837,10 +967,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( @@ -866,49 +999,70 @@ 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); + const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); + const logicalProjectKey = deriveLogicalProjectKey(activeProject); + const storedDraftThread = getDraftThreadByLogicalProjectKey(logicalProjectKey); if (storedDraftThread) { - setDraftThreadContext(storedDraftThread.threadId, input); - setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); + setDraftThreadContext(storedDraftThread.threadRef, input); + setLogicalProjectDraftThreadId( + logicalProjectKey, + activeProjectRef, + storedDraftThread.isLocalDraft ? storedDraftThread.threadId : storedDraftThread.threadRef, + input, + ); if (storedDraftThread.threadId !== threadId) { - await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, - }); + if (storedDraftThread.isLocalDraft) { + await navigate({ + to: "/draft/$threadId", + params: buildDraftThreadRouteParams(storedDraftThread.threadId), + }); + } else { + await navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId: activeProject.environmentId, + threadId: storedDraftThread.threadId, + }, + }); + } } return storedDraftThread.threadId; } - const activeDraftThread = getDraftThread(threadId); - if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { + const activeDraftThread = routeKind === "draft" ? getDraftThread(threadId) : null; + if (!isServerThread && activeDraftThread?.logicalProjectKey === logicalProjectKey) { setDraftThreadContext(threadId, input); - setProjectDraftThreadId(activeProject.id, threadId, input); + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, threadId, { + createdAt: activeDraftThread.createdAt, + runtimeMode: activeDraftThread.runtimeMode, + interactionMode: activeDraftThread.interactionMode, + ...input, + }); return threadId; } - clearProjectDraftThreadId(activeProject.id); const nextThreadId = newThreadId(); - setProjectDraftThreadId(activeProject.id, nextThreadId, { + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextThreadId, { createdAt: new Date().toISOString(), runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); await navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, + to: "/draft/$threadId", + params: buildDraftThreadRouteParams(nextThreadId), }); return nextThreadId; }, [ activeProject, - clearProjectDraftThreadId, getDraftThread, - getDraftThreadByProjectId, + getDraftThreadByLogicalProjectKey, isServerThread, navigate, + routeKind, setDraftThreadContext, - setProjectDraftThreadId, + setLogicalProjectDraftThreadId, threadId, ], ); @@ -933,12 +1087,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, ]); @@ -958,7 +1113,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ - threadId, + threadRef: routeThreadRef, providers: providerStatuses, selectedProvider, threadModelSelection: activeThread?.modelSelection, @@ -1357,7 +1512,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( @@ -1393,6 +1548,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ + environmentId, cwd: gitCwd, query: effectivePathQuery, enabled: isPathTrigger, @@ -1530,16 +1686,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 && @@ -1560,7 +1722,12 @@ 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 = + activeThread !== undefined && + targetThreadId === routeThreadRef.threadId && + activeThread.environmentId === routeThreadRef.environmentId && + activeThread.id === routeThreadRef.threadId; + if (isCurrentServerThread) { setStoreThreadError(targetThreadId, nextError); return; } @@ -1574,7 +1741,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }); }, - [setStoreThreadError], + [activeThread, routeThreadRef, setStoreThreadError], ); const focusComposer = useCallback(() => { @@ -1605,7 +1772,7 @@ export default function ChatView({ threadId }: ChatViewProps) { insertion.cursor, ); const inserted = insertComposerDraftTerminalContext( - activeThread.id, + scopeThreadRef(activeThread.environmentId, activeThread.id), insertion.prompt, { id: randomUUID(), @@ -1629,30 +1796,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 = () => @@ -1675,10 +1842,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 ( @@ -1691,7 +1866,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) => { @@ -1718,10 +1893,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); @@ -1768,12 +1946,14 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeThread, activeThreadId, + activeThreadRef, gitCwd, setTerminalOpen, setThreadError, storeNewTerminal, storeSetActiveTerminal, setLastInvokedScriptByProjectId, + environmentId, terminalState.activeTerminalId, terminalState.runningTerminalIds, terminalState.terminalIds, @@ -1789,7 +1969,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({ @@ -1805,10 +1985,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) => { @@ -1912,9 +2096,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(threadId, mode); + setComposerDraftRuntimeMode(routeThreadRef, mode); if (isLocalDraftThread) { - setDraftThreadContext(threadId, { runtimeMode: mode }); + setDraftThreadContext(routeThreadRef, { runtimeMode: mode }); } scheduleComposerFocus(); }, @@ -1924,16 +2108,16 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftRuntimeMode, setDraftThreadContext, - threadId, + routeThreadRef, ], ); const handleInteractionModeChange = useCallback( (mode: ProviderInteractionMode) => { if (mode === interactionMode) return; - setComposerDraftInteractionMode(threadId, mode); + setComposerDraftInteractionMode(routeThreadRef, mode); if (isLocalDraftThread) { - setDraftThreadContext(threadId, { interactionMode: mode }); + setDraftThreadContext(routeThreadRef, { interactionMode: mode }); } scheduleComposerFocus(); }, @@ -1943,7 +2127,7 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftInteractionMode, setDraftThreadContext, - threadId, + routeThreadRef, ], ); const toggleInteractionMode = useCallback(() => { @@ -1979,7 +2163,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!serverThread) { return; } - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api) { return; } @@ -2019,7 +2203,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } }, - [serverThread], + [environmentId, serverThread], ); // Auto-scroll on new messages @@ -2355,11 +2539,12 @@ export default function ChatView({ threadId }: ChatViewProps) { let cancelled = false; void (async () => { if (composerImages.length === 0) { - clearComposerDraftPersistedAttachments(threadId); + clearComposerDraftPersistedAttachments(routeThreadRef); return; } const getPersistedAttachmentsForThread = () => - useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; + useComposerDraftStore.getState().draftsByThreadKey[scopedThreadKey(routeThreadRef)] + ?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); const existingPersistedById = new Map( @@ -2390,7 +2575,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(routeThreadRef, serialized); } catch { const currentImageIds = new Set(composerImages.map((image) => image.id)); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); @@ -2404,7 +2589,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (cancelled) { return; } - syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); + syncComposerDraftPersistedAttachments(routeThreadRef, fallbackAttachments); } })(); return () => { @@ -2413,8 +2598,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [ clearComposerDraftPersistedAttachments, composerImages, + routeThreadRef, syncComposerDraftPersistedAttachments, - threadId, ]); const closeExpandedImage = useCallback(() => { @@ -2475,7 +2660,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { if (!activeThreadId) { setTerminalLaunchContext(null); - storeClearTerminalLaunchContext(threadId); + storeClearTerminalLaunchContext(routeThreadRef); return; } setTerminalLaunchContext((current) => { @@ -2483,7 +2668,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) { @@ -2501,12 +2686,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) { @@ -2520,11 +2713,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, @@ -2534,11 +2730,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; @@ -2551,16 +2752,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(); }); @@ -2569,8 +2770,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) => { @@ -2765,14 +2966,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.", @@ -2801,12 +3003,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(); @@ -2829,7 +3039,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); @@ -2846,7 +3056,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); @@ -2949,7 +3159,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } promptRef.current = ""; - clearComposerDraftContent(threadIdForSend); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -3086,7 +3296,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", @@ -3098,7 +3308,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) => @@ -3121,12 +3331,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) => @@ -3149,7 +3359,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, setThreadError], + [activeThreadId, environmentId, setThreadError], ); const setActivePendingUserInputQuestionIndex = useCallback( @@ -3251,7 +3461,7 @@ export default function ChatView({ threadId }: ChatViewProps) { text: string; interactionMode: "default" | "plan"; }) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if ( !api || !activeThread || @@ -3306,7 +3516,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", @@ -3370,11 +3583,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 || @@ -3446,17 +3660,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", @@ -3488,6 +3705,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, selectedProviderModels, selectedModel, + environmentId, ]); const onProviderModelSelect = useCallback( @@ -3508,7 +3726,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(); }, @@ -3540,7 +3761,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, - threadId, + threadRef: routeThreadRef, model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], @@ -3549,7 +3770,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, - threadId, + threadRef: routeThreadRef, model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], @@ -3559,11 +3780,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { - setDraftThreadContext(threadId, { envMode: mode }); + setDraftThreadContext(routeThreadRef, { envMode: mode }); } scheduleComposerFocus(); }, - [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], + [isLocalDraftThread, routeThreadRef, scheduleComposerFocus, setDraftThreadContext], ); const applyPromptReplacement = useCallback( @@ -3760,7 +3981,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(nextPrompt); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { setComposerDraftTerminalContexts( - threadId, + routeThreadRef, syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), ); } @@ -3776,7 +3997,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onChangeActivePendingUserInputCustomAnswer, setPrompt, setComposerDraftTerminalContexts, - threadId, + routeThreadRef, ], ); @@ -3829,9 +4050,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 @@ -3840,7 +4067,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }); }, - [navigate, threadId], + [environmentId, isServerThread, navigate, threadId], ); const onRevertUserMessage = (messageId: MessageId) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); @@ -3886,6 +4113,7 @@ export default function ChatView({ threadId }: ChatViewProps) { )} > {/* 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..67495b9d14 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,6 +6,7 @@ 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"; @@ -133,20 +135,38 @@ vi.mock("~/lib/utils", async () => { }; }); -vi.mock("~/nativeApi", () => ({ - readNativeApi: vi.fn(() => null), +vi.mock("~/localApi", () => ({ + readLocalApi: vi.fn(() => null), })); -vi.mock("~/store", () => ({ - 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 }, - }, - }), -})); +vi.mock("~/store", async () => { + const actual = await vi.importActual("~/store"); + return { + ...actual, + useStore: (selector: (state: unknown) => unknown) => + selector({ + setThreadBranch: setThreadBranchSpy, + 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: {}, + }, + }, + }), + }; +}); vi.mock("~/terminal-links", () => ({ resolvePathLinkTarget: vi.fn(), @@ -168,7 +188,10 @@ function Harness() { - + ); } @@ -248,9 +271,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,7 +293,10 @@ 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); 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 923d9d88e8..a050962dbd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -45,10 +45,18 @@ import { CSS } from "@dnd-kit/utilities"; import { DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, + type EnvironmentId, ProjectId, + type ScopedThreadRef, ThreadId, type GitStatusResult, } from "@t3tools/contracts"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, @@ -58,7 +66,11 @@ 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, + selectSidebarThreadsAcrossEnvironments, + useStore, +} from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { @@ -70,11 +82,12 @@ 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 } from "../threadRoutes"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -110,7 +123,6 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getVisibleSidebarThreadIds, - getVisibleThreadsForProject, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, @@ -126,9 +138,10 @@ import { } 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", @@ -145,6 +158,7 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { } as const; type SidebarProjectSnapshot = Project & { + projectKey: string; expanded: boolean; }; interface TerminalStatusIndicator { @@ -255,56 +269,61 @@ function resolveThreadPr( } interface SidebarThreadRowProps { - threadId: ThreadId; + thread: SidebarThreadSummary; projectCwd: string | null; - orderedProjectThreadIds: readonly ThreadId[]; - routeThreadId: ThreadId | null; - selectedThreadIds: ReadonlySet; + orderedProjectThreadKeys: readonly string[]; + routeThreadKey: string | null; + selectedThreadKeys: ReadonlySet; showThreadJumpHints: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; - renamingThreadId: ThreadId | null; + renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; renamingInputRef: MutableRefObject; renamingCommittedRef: MutableRefObject; - confirmingArchiveThreadId: ThreadId | null; - setConfirmingArchiveThreadId: Dispatch>; - confirmArchiveButtonRefs: MutableRefObject>; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: Dispatch>; + confirmArchiveButtonRefs: MutableRefObject>; handleThreadClick: ( event: MouseEvent, - threadId: ThreadId, - orderedProjectThreadIds: readonly ThreadId[], + 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; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: 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 thread = props.thread; + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); 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 gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useGitStatus({ + environmentId: thread.environmentId, + cwd: thread.branch != null ? gitCwd : null, + }); - const isActive = props.routeThreadId === thread.id; - const isSelected = props.selectedThreadIds.has(thread.id); + const isActive = props.routeThreadKey === threadKey; + const isSelected = props.selectedThreadKeys.has(threadKey); const isHighlighted = isActive || isSelected; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; @@ -317,7 +336,7 @@ 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 = props.confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" : !isThreadRunning @@ -329,7 +348,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { className="w-full" data-thread-item onMouseLeave={() => { - props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + props.setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); }} onBlurCapture={(event) => { const currentTarget = event.currentTarget; @@ -337,7 +356,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { if (currentTarget.contains(document.activeElement)) { return; } - props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + props.setConfirmingArchiveThreadKey((current) => + current === threadKey ? null : current, + ); }); }} > @@ -351,25 +372,25 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { isSelected, })} relative isolate`} onClick={(event) => { - props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds); + props.handleThreadClick(event, threadRef, props.orderedProjectThreadKeys); }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - props.navigateToThread(thread.id); + props.navigateToThread(threadRef); }} onContextMenu={(event) => { event.preventDefault(); - if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) { + if (props.selectedThreadKeys.size > 0 && props.selectedThreadKeys.has(threadKey)) { void props.handleMultiSelectContextMenu({ x: event.clientX, y: event.clientY, }); } else { - if (props.selectedThreadIds.size > 0) { + if (props.selectedThreadKeys.size > 0) { props.clearSelection(); } - void props.handleThreadContextMenu(thread.id, { + void props.handleThreadContextMenu(threadRef, { x: event.clientX, y: event.clientY, }); @@ -397,7 +418,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { )} {threadStatus && } - {props.renamingThreadId === thread.id ? ( + {props.renamingThreadKey === threadKey ? ( { if (element && props.renamingInputRef.current !== element) { @@ -414,7 +435,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { if (event.key === "Enter") { event.preventDefault(); props.renamingCommittedRef.current = true; - void props.commitRename(thread.id, props.renamingTitle, thread.title); + void props.commitRename(threadRef, props.renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); props.renamingCommittedRef.current = true; @@ -423,7 +444,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { }} onBlur={() => { if (!props.renamingCommittedRef.current) { - void props.commitRename(thread.id, props.renamingTitle, thread.title); + void props.commitRename(threadRef, props.renamingTitle, thread.title); } }} onClick={(event) => event.stopPropagation()} @@ -448,9 +469,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {