From 8ccfe3ee5b740347b54dccdb7f1e837f9d790c0c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 12:48:13 -0700 Subject: [PATCH 01/14] Surface environment and repository identity metadata - Persist a stable server environment ID and descriptor - Resolve repository identity from git remotes and enrich orchestration events - Thread environment metadata through desktop and web startup flows --- apps/desktop/src/main.ts | 9 + apps/desktop/src/preload.ts | 8 + .../Layers/ServerEnvironment.test.ts | 33 ++++ .../environment/Layers/ServerEnvironment.ts | 99 ++++++++++ .../environment/Services/ServerEnvironment.ts | 13 ++ apps/server/src/git/Layers/GitManager.test.ts | 6 +- .../Layers/ProjectionSnapshotQuery.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.ts | 50 +++-- .../Layers/RepositoryIdentityResolver.test.ts | 76 ++++++++ .../Layers/RepositoryIdentityResolver.ts | 137 ++++++++++++++ .../Services/RepositoryIdentityResolver.ts | 12 ++ apps/server/src/server.test.ts | 173 +++++++++++++++++- apps/server/src/server.ts | 4 + apps/server/src/serverLifecycleEvents.test.ts | 10 + apps/server/src/serverRuntimeStartup.ts | 14 +- apps/server/src/ws.ts | 109 ++++++++--- apps/web/package.json | 1 + apps/web/src/components/ChatView.browser.tsx | 15 ++ .../components/KeybindingsToast.browser.tsx | 15 ++ .../settings/SettingsPanels.browser.tsx | 14 +- apps/web/src/environmentBootstrap.ts | 65 +++++++ apps/web/src/lib/utils.ts | 3 +- apps/web/src/rpc/serverState.test.ts | 18 ++ apps/web/src/types.ts | 6 + apps/web/src/wsNativeApi.test.ts | 16 ++ apps/web/src/wsRpcClient.ts | 5 +- apps/web/src/wsTransport.test.ts | 21 +++ bun.lock | 15 ++ packages/client-runtime/package.json | 25 +++ packages/client-runtime/src/index.ts | 2 + .../src/knownEnvironment.test.ts | 46 +++++ .../client-runtime/src/knownEnvironment.ts | 50 +++++ packages/client-runtime/src/scoped.ts | 60 ++++++ packages/client-runtime/tsconfig.json | 4 + packages/contracts/src/baseSchemas.ts | 2 + packages/contracts/src/environment.ts | 77 ++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 6 + packages/contracts/src/orchestration.ts | 4 + packages/contracts/src/server.ts | 4 + packages/shared/src/git.test.ts | 31 +++- packages/shared/src/git.ts | 38 ++++ 42 files changed, 1250 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/environment/Layers/ServerEnvironment.test.ts create mode 100644 apps/server/src/environment/Layers/ServerEnvironment.ts create mode 100644 apps/server/src/environment/Services/ServerEnvironment.ts create mode 100644 apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts create mode 100644 apps/server/src/project/Layers/RepositoryIdentityResolver.ts create mode 100644 apps/server/src/project/Services/RepositoryIdentityResolver.ts create mode 100644 apps/web/src/environmentBootstrap.ts create mode 100644 packages/client-runtime/package.json create mode 100644 packages/client-runtime/src/index.ts create mode 100644 packages/client-runtime/src/knownEnvironment.test.ts create mode 100644 packages/client-runtime/src/knownEnvironment.ts create mode 100644 packages/client-runtime/src/scoped.ts create mode 100644 packages/client-runtime/tsconfig.json create mode 100644 packages/contracts/src/environment.ts 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/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/Layers/ServerEnvironment.test.ts new file mode 100644 index 0000000000..35b6803fc9 --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironment.test.ts @@ -0,0 +1,33 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer } from "effect"; + +import { ServerConfig } 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))); + +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); + }), + ); +}); diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts new file mode 100644 index 0000000000..2978af7dcb --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -0,0 +1,99 @@ +import { randomUUID } from "node:crypto"; +import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; +import { version } from "../../../package.json" with { type: "json" }; + +const ENVIRONMENT_ID_FILENAME = "environment-id"; + +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.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + const environmentIdPath = path.join(serverConfig.stateDir, ENVIRONMENT_ID_FILENAME); + + const readPersistedEnvironmentId = Effect.gen(function* () { + const exists = yield* fileSystem + .exists(environmentIdPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return null; + } + + const raw = yield* fileSystem.readFileString(environmentIdPath).pipe( + Effect.orElseSucceed(() => ""), + Effect.map((value) => value.trim()), + ); + + return raw.length > 0 ? raw : null; + }); + + const persistEnvironmentId = (value: string) => + fileSystem.writeFileString(environmentIdPath, `${value}\n`); + + const environmentIdRaw = yield* readPersistedEnvironmentId.pipe( + Effect.flatMap((persisted) => { + if (persisted) { + return Effect.succeed(persisted); + } + + const generated = randomUUID(); + return persistEnvironmentId(generated).pipe(Effect.as(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..ff44e20dbf 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,25 @@ 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.map((option) => option), + 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 +838,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..8b1f4ce528 --- /dev/null +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -0,0 +1,76 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; + +import { runProcess } from "../../processRunner.ts"; +import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; +import { RepositoryIdentityResolverLive } from "./RepositoryIdentityResolver.ts"; + +const git = (cwd: string, args: ReadonlyArray) => + Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); + +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)), + ); +}); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts new file mode 100644 index 0000000000..9d94518afe --- /dev/null +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -0,0 +1,137 @@ +import type { RepositoryIdentity } from "@t3tools/contracts"; +import { Effect, Layer, Ref } from "effect"; +import { runProcess } from "../../processRunner.ts"; +import { + normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, +} from "@t3tools/shared/git"; + +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 githubNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(input.remoteUrl); + const [owner, repositoryName] = githubNameWithOwner?.split("/") ?? []; + + return { + canonicalKey, + locator: { + source: "git-remote", + remoteName: input.remoteName, + remoteUrl: input.remoteUrl, + }, + ...(githubNameWithOwner ? { displayName: githubNameWithOwner } : {}), + ...(githubNameWithOwner ? { provider: "github" } : {}), + ...(owner ? { owner } : {}), + ...(repositoryName ? { name: repositoryName } : {}), + }; +} + +async function resolveRepositoryIdentity(cwd: string): Promise<{ + readonly cacheKey: string; + readonly identity: RepositoryIdentity | null; +}> { + let topLevel = cwd; + + try { + const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { + allowNonZeroExit: true, + }); + if (topLevelResult.code !== 0) { + return { cacheKey: cwd, identity: null }; + } + + const candidate = topLevelResult.stdout.trim(); + if (candidate.length > 0) { + topLevel = candidate; + } + } catch { + return { cacheKey: cwd, identity: null }; + } + + try { + const remoteResult = await runProcess("git", ["-C", topLevel, "remote", "-v"], { + allowNonZeroExit: true, + }); + if (remoteResult.code !== 0) { + return { cacheKey: topLevel, identity: null }; + } + + const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); + return { + cacheKey: topLevel, + identity: remote ? buildRepositoryIdentity(remote) : null, + }; + } catch { + return { cacheKey: topLevel, identity: null }; + } +} + +export const makeRepositoryIdentityResolver = Effect.gen(function* () { + const cacheRef = yield* Ref.make(new Map()); + + const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( + "RepositoryIdentityResolver.resolve", + )(function* (cwd) { + const cache = yield* Ref.get(cacheRef); + const cached = cache.get(cwd); + if (cached !== undefined) { + return cached; + } + + const resolved = yield* Effect.promise(() => resolveRepositoryIdentity(cwd)); + yield* Ref.update(cacheRef, (current) => { + const next = new Map(current); + next.set(cwd, resolved.identity); + next.set(resolved.cacheKey, resolved.identity); + return next; + }); + return resolved.identity; + }); + + 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..6762a89129 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,72 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).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..e493503c84 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,6 +505,7 @@ 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); @@ -470,33 +521,43 @@ const WsRpcLayer = WsRpcGroup.toLayer( return source.pipe( Stream.mapEffect((event) => - Ref.modify( - state, - ({ - nextSequence, - pendingBySequence, - }): [Array, SequenceState] => { - if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { - return [[], { nextSequence, pendingBySequence }]; - } + enrichProjectEvent(event).pipe( + Effect.flatMap((enrichedEvent) => + Ref.modify( + state, + ({ + nextSequence, + pendingBySequence, + }): [Array, SequenceState] => { + if ( + enrichedEvent.sequence < nextSequence || + pendingBySequence.has(enrichedEvent.sequence) + ) { + return [[], { nextSequence, pendingBySequence }]; + } - const updatedPending = new Map(pendingBySequence); - updatedPending.set(event.sequence, event); + const updatedPending = new Map(pendingBySequence); + updatedPending.set(enrichedEvent.sequence, enrichedEvent); - const emit: Array = []; - let expected = nextSequence; - for (;;) { - const expectedEvent = updatedPending.get(expected); - if (!expectedEvent) { - break; - } - emit.push(expectedEvent); - updatedPending.delete(expected); - expected += 1; - } + const emit: Array = []; + let expected = nextSequence; + for (;;) { + const expectedEvent = updatedPending.get(expected); + if (!expectedEvent) { + break; + } + emit.push(expectedEvent); + updatedPending.delete(expected); + expected += 1; + } - return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; - }, + return [ + emit, + { nextSequence: expected, pendingBySequence: updatedPending }, + ]; + }, + ), + ), ), ), Stream.flatMap((events) => Stream.fromIterable(events)), 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/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 8aa15a06d5..1cdd7ec2e4 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, @@ -128,6 +129,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 +321,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, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 1ee13f460f..d645546042 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, @@ -49,6 +50,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); 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: [], @@ -155,6 +163,13 @@ function buildFixture(): TestFixture { snapshot: createMinimalSnapshot(), 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, diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index f0ea32d4be..14bb39972d 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1,6 +1,11 @@ import "../../index.css"; -import { DEFAULT_SERVER_SETTINGS, type NativeApi, type ServerConfig } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + type NativeApi, + type ServerConfig, +} from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -12,6 +17,13 @@ import { GeneralSettingsPanel } from "./SettingsPanels"; function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], diff --git a/apps/web/src/environmentBootstrap.ts b/apps/web/src/environmentBootstrap.ts new file mode 100644 index 0000000000..860c459edf --- /dev/null +++ b/apps/web/src/environmentBootstrap.ts @@ -0,0 +1,65 @@ +import { + createKnownEnvironmentFromWsUrl, + getKnownEnvironmentBaseUrl, + type KnownEnvironment, +} from "@t3tools/client-runtime"; +import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; + +function createKnownEnvironmentFromDesktopBootstrap( + bootstrap: DesktopEnvironmentBootstrap | null | undefined, +): KnownEnvironment | null { + if (!bootstrap?.wsUrl) { + return null; + } + + return createKnownEnvironmentFromWsUrl({ + id: `desktop:${bootstrap.label}`, + label: bootstrap.label, + source: "desktop-managed", + wsUrl: bootstrap.wsUrl, + }); +} + +export function getPrimaryKnownEnvironment(): KnownEnvironment | null { + const desktopEnvironment = createKnownEnvironmentFromDesktopBootstrap( + window.desktopBridge?.getLocalEnvironmentBootstrap(), + ); + if (desktopEnvironment) { + return desktopEnvironment; + } + + const legacyDesktopWsUrl = window.desktopBridge?.getWsUrl(); + if (typeof legacyDesktopWsUrl === "string" && legacyDesktopWsUrl.length > 0) { + return createKnownEnvironmentFromWsUrl({ + id: "desktop-legacy", + label: "Local environment", + source: "desktop-managed", + wsUrl: legacyDesktopWsUrl, + }); + } + + const configuredWsUrl = import.meta.env.VITE_WS_URL; + if (typeof configuredWsUrl === "string" && configuredWsUrl.length > 0) { + return createKnownEnvironmentFromWsUrl({ + id: "configured-primary", + label: "Primary environment", + source: "configured", + wsUrl: configuredWsUrl, + }); + } + + return createKnownEnvironmentFromWsUrl({ + id: "window-origin", + label: "Primary environment", + source: "window-origin", + wsUrl: window.location.origin, + }); +} + +export function resolvePrimaryEnvironmentBootstrapUrl(): string { + const baseUrl = getKnownEnvironmentBaseUrl(getPrimaryKnownEnvironment()); + if (!baseUrl) { + throw new Error("Unable to resolve a known environment bootstrap URL."); + } + return baseUrl; +} diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index e48f815461..5b0bcec4bd 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -4,6 +4,7 @@ import { type CxOptions, cx } from "class-variance-authority"; import { twMerge } from "tailwind-merge"; import * as Random from "effect/Random"; import * as Effect from "effect/Effect"; +import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap"; export function cn(...inputs: CxOptions) { return twMerge(cx(inputs)); @@ -54,7 +55,7 @@ export const resolveServerUrl = (options?: { }): string => { const rawUrl = firstNonEmptyString( options?.url, - window.desktopBridge?.getWsUrl(), + resolvePrimaryEnvironmentBootstrapUrl(), import.meta.env.VITE_WS_URL, window.location.origin, ); diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index 721ce25fb5..4eb198324d 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ProjectId, ThreadId, type ServerConfig, @@ -50,7 +51,21 @@ const defaultProviders: ReadonlyArray = [ }, ]; +const baseEnvironment = { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; + const baseServerConfig: ServerConfig = { + environment: baseEnvironment, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], @@ -193,6 +208,7 @@ describe("serverState", () => { sequence: 1, type: "welcome", payload: { + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), @@ -201,6 +217,7 @@ describe("serverState", () => { }); expect(listener).toHaveBeenCalledWith({ + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), @@ -210,6 +227,7 @@ describe("serverState", () => { const lateListener = vi.fn(); const unsubscribeLate = onWelcome(lateListener); expect(lateListener).toHaveBeenCalledWith({ + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 972cf42bab..a54b195428 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,7 +1,9 @@ import type { + EnvironmentId, ModelSelection, OrchestrationLatestTurn, OrchestrationProposedPlanId, + RepositoryIdentity, OrchestrationSessionStatus, OrchestrationThreadActivity, ProjectScript as ContractProjectScript, @@ -80,8 +82,10 @@ export interface TurnDiffSummary { export interface Project { id: ProjectId; + environmentId?: EnvironmentId | null; name: string; cwd: string; + repositoryIdentity?: RepositoryIdentity | null; defaultModelSelection: ModelSelection | null; createdAt?: string | undefined; updatedAt?: string | undefined; @@ -90,6 +94,7 @@ export interface Project { export interface Thread { id: ThreadId; + environmentId?: EnvironmentId | null; codexThreadId: string | null; projectId: ProjectId; title: string; @@ -134,6 +139,7 @@ export interface ThreadTurnState { export interface SidebarThreadSummary { id: ThreadId; + environmentId?: EnvironmentId | null; projectId: ProjectId; title: string; interactionMode: ProviderInteractionMode; diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index ae56f85991..fef6e0c5d6 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_SERVER_SETTINGS, type DesktopBridge, + EnvironmentId, EventId, type GitStatusResult, ProjectId, @@ -121,6 +122,7 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn function makeDesktopBridge(overrides: Partial = {}): DesktopBridge { return { getWsUrl: () => null, + getLocalEnvironmentBootstrap: () => null, pickFolder: async () => null, confirm: async () => true, setTheme: async () => undefined, @@ -157,7 +159,21 @@ const defaultProviders: ReadonlyArray = [ }, ]; +const baseEnvironment = { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; + const baseServerConfig: ServerConfig = { + environment: baseEnvironment, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 997b83d2d7..0eb37e3a2b 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -12,6 +12,7 @@ import { import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; import { Effect, Stream } from "effect"; +import { resolvePrimaryEnvironmentBootstrapUrl } from "./environmentBootstrap"; import { type WsRpcProtocolClient } from "./rpc/protocol"; import { resetWsReconnectBackoff } from "./rpc/wsConnectionState"; import { WsTransport } from "./wsTransport"; @@ -124,7 +125,9 @@ export async function __resetWsRpcClientForTests() { sharedWsRpcClient = null; } -export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { +export function createWsRpcClient( + transport = new WsTransport(resolvePrimaryEnvironmentBootstrapUrl()), +): WsRpcClient { return { dispose: () => transport.dispose(), reconnect: async () => { diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index da5404b239..58453d9913 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -436,6 +436,13 @@ describe("WsTransport", () => { sequence: 1, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/workspace", projectName: "workspace", }, @@ -489,6 +496,13 @@ describe("WsTransport", () => { sequence: 1, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/one", projectName: "one", }, @@ -532,6 +546,13 @@ describe("WsTransport", () => { sequence: 2, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/two", projectName: "two", }, diff --git a/bun.lock b/bun.lock index af243cf4eb..74c5badbe6 100644 --- a/bun.lock +++ b/bun.lock @@ -82,6 +82,7 @@ "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", @@ -121,6 +122,18 @@ "vitest-browser-react": "^2.0.5", }, }, + "packages/client-runtime": { + "name": "@t3tools/client-runtime", + "version": "0.0.0-alpha.1", + "dependencies": { + "@t3tools/contracts": "workspace:*", + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, "packages/contracts": { "name": "@t3tools/contracts", "version": "0.0.15", @@ -659,6 +672,8 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@t3tools/client-runtime": ["@t3tools/client-runtime@workspace:packages/client-runtime"], + "@t3tools/contracts": ["@t3tools/contracts@workspace:packages/contracts"], "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"], diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json new file mode 100644 index 0000000000..bfe2d7828e --- /dev/null +++ b/packages/client-runtime/package.json @@ -0,0 +1,25 @@ +{ + "name": "@t3tools/client-runtime", + "version": "0.0.0-alpha.1", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "prepare": "effect-language-service patch", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*" + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts new file mode 100644 index 0000000000..5dd6b9afa5 --- /dev/null +++ b/packages/client-runtime/src/index.ts @@ -0,0 +1,2 @@ +export * from "./knownEnvironment"; +export * from "./scoped"; diff --git a/packages/client-runtime/src/knownEnvironment.test.ts b/packages/client-runtime/src/knownEnvironment.test.ts new file mode 100644 index 0000000000..70cf7996a0 --- /dev/null +++ b/packages/client-runtime/src/knownEnvironment.test.ts @@ -0,0 +1,46 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { createKnownEnvironmentFromWsUrl } from "./knownEnvironment"; +import { scopedRefKey, scopeProjectRef, scopeThreadRef } from "./scoped"; + +describe("known environment bootstrap helpers", () => { + it("creates known environments from explicit ws urls", () => { + expect( + createKnownEnvironmentFromWsUrl({ + label: "Remote environment", + wsUrl: "wss://remote.example.com/ws", + }), + ).toEqual({ + id: "ws:Remote environment", + label: "Remote environment", + source: "manual", + target: { + type: "ws", + wsUrl: "wss://remote.example.com/ws", + }, + }); + }); +}); + +describe("scoped refs", () => { + const environmentId = EnvironmentId.makeUnsafe("environment-test"); + const projectRef = scopeProjectRef(environmentId, ProjectId.makeUnsafe("project-1")); + const threadRef = scopeThreadRef(environmentId, ThreadId.makeUnsafe("thread-1")); + + it("builds stable scoped project and thread keys", () => { + expect(scopedRefKey(projectRef)).toBe("environment-test:project-1"); + expect(scopedRefKey(threadRef)).toBe("environment-test:thread-1"); + }); + + it("returns typed scoped refs", () => { + expect(projectRef).toEqual({ + environmentId, + projectId: ProjectId.makeUnsafe("project-1"), + }); + expect(threadRef).toEqual({ + environmentId, + threadId: ThreadId.makeUnsafe("thread-1"), + }); + }); +}); diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/knownEnvironment.ts new file mode 100644 index 0000000000..40c9054f17 --- /dev/null +++ b/packages/client-runtime/src/knownEnvironment.ts @@ -0,0 +1,50 @@ +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; + +export interface KnownEnvironmentConnectionTarget { + readonly type: "ws"; + readonly wsUrl: string; +} + +export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin"; + +export interface KnownEnvironment { + readonly id: string; + readonly label: string; + readonly source: KnownEnvironmentSource; + readonly environmentId?: EnvironmentId; + readonly target: KnownEnvironmentConnectionTarget; +} + +export function createKnownEnvironmentFromWsUrl(input: { + readonly id?: string; + readonly label: string; + readonly source?: KnownEnvironmentSource; + readonly wsUrl: string; +}): KnownEnvironment { + return { + id: input.id ?? `ws:${input.label}`, + label: input.label, + source: input.source ?? "manual", + target: { + type: "ws", + wsUrl: input.wsUrl, + }, + }; +} + +export function getKnownEnvironmentBaseUrl( + environment: KnownEnvironment | null | undefined, +): string | null { + return environment?.target.wsUrl ?? null; +} + +export function attachEnvironmentDescriptor( + environment: KnownEnvironment, + descriptor: ExecutionEnvironmentDescriptor, +): KnownEnvironment { + return { + ...environment, + environmentId: descriptor.environmentId, + label: descriptor.label, + }; +} diff --git a/packages/client-runtime/src/scoped.ts b/packages/client-runtime/src/scoped.ts new file mode 100644 index 0000000000..54ffd6dcbb --- /dev/null +++ b/packages/client-runtime/src/scoped.ts @@ -0,0 +1,60 @@ +import type { + EnvironmentId, + ProjectId, + ScopedProjectRef, + ScopedThreadSessionRef, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; + +interface EnvironmentScopedRef { + readonly environmentId: EnvironmentId; + readonly id: TId; +} + +export interface EnvironmentClientRegistry { + readonly getClient: (environmentId: EnvironmentId) => TClient | null | undefined; +} + +export function scopeProjectRef( + environmentId: EnvironmentId, + projectId: ProjectId, +): ScopedProjectRef { + return { environmentId, projectId }; +} + +export function scopeThreadRef(environmentId: EnvironmentId, threadId: ThreadId): ScopedThreadRef { + return { environmentId, threadId }; +} + +export function scopeThreadSessionRef( + environmentId: EnvironmentId, + threadId: ThreadId, +): ScopedThreadSessionRef { + return { environmentId, threadId }; +} + +export function scopedRefKey( + ref: EnvironmentScopedRef | ScopedProjectRef | ScopedThreadRef | ScopedThreadSessionRef, +): string { + const localId = "id" in ref ? ref.id : "projectId" in ref ? ref.projectId : ref.threadId; + return `${ref.environmentId}:${localId}`; +} + +export function resolveEnvironmentClient( + registry: EnvironmentClientRegistry, + ref: EnvironmentScopedRef, +): TClient { + const client = registry.getClient(ref.environmentId); + if (!client) { + throw new Error(`No client registered for environment ${ref.environmentId}.`); + } + return client; +} + +export function tagEnvironmentValue( + environmentId: EnvironmentId, + value: T, +): { readonly environmentId: EnvironmentId; readonly value: T } { + return { environmentId, value }; +} diff --git a/packages/client-runtime/tsconfig.json b/packages/client-runtime/tsconfig.json new file mode 100644 index 0000000000..564a599005 --- /dev/null +++ b/packages/client-runtime/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 24962aed69..5a199e9a67 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -19,6 +19,8 @@ export const ThreadId = makeEntityId("ThreadId"); export type ThreadId = typeof ThreadId.Type; export const ProjectId = makeEntityId("ProjectId"); export type ProjectId = typeof ProjectId.Type; +export const EnvironmentId = makeEntityId("EnvironmentId"); +export type EnvironmentId = typeof EnvironmentId.Type; export const CommandId = makeEntityId("CommandId"); export type CommandId = typeof CommandId.Type; export const EventId = makeEntityId("EventId"); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts new file mode 100644 index 0000000000..9e97be83ea --- /dev/null +++ b/packages/contracts/src/environment.ts @@ -0,0 +1,77 @@ +import { Schema } from "effect"; + +import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; + +export const ExecutionEnvironmentPlatformOs = Schema.Literals([ + "darwin", + "linux", + "windows", + "unknown", +]); +export type ExecutionEnvironmentPlatformOs = typeof ExecutionEnvironmentPlatformOs.Type; + +export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]); +export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; + +export const ExecutionEnvironmentPlatform = Schema.Struct({ + os: ExecutionEnvironmentPlatformOs, + arch: ExecutionEnvironmentPlatformArch, +}); +export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; + +export const ExecutionEnvironmentCapabilities = Schema.Struct({ + repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), +}); +export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; + +export const ExecutionEnvironmentDescriptor = Schema.Struct({ + environmentId: EnvironmentId, + label: TrimmedNonEmptyString, + platform: ExecutionEnvironmentPlatform, + serverVersion: TrimmedNonEmptyString, + capabilities: ExecutionEnvironmentCapabilities, +}); +export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; + +export const EnvironmentConnectionState = Schema.Literals([ + "connecting", + "connected", + "disconnected", + "error", +]); +export type EnvironmentConnectionState = typeof EnvironmentConnectionState.Type; + +export const RepositoryIdentityLocator = Schema.Struct({ + source: Schema.Literal("git-remote"), + remoteName: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, +}); +export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; + +export const RepositoryIdentity = Schema.Struct({ + canonicalKey: TrimmedNonEmptyString, + locator: RepositoryIdentityLocator, + displayName: Schema.optionalKey(TrimmedNonEmptyString), + provider: Schema.optionalKey(TrimmedNonEmptyString), + owner: Schema.optionalKey(TrimmedNonEmptyString), + name: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type RepositoryIdentity = typeof RepositoryIdentity.Type; + +export const ScopedProjectRef = Schema.Struct({ + environmentId: EnvironmentId, + projectId: ProjectId, +}); +export type ScopedProjectRef = typeof ScopedProjectRef.Type; + +export const ScopedThreadRef = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type ScopedThreadRef = typeof ScopedThreadRef.Type; + +export const ScopedThreadSessionRef = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type ScopedThreadSessionRef = typeof ScopedThreadSessionRef.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c60856bbe5..d2f84eda9d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas"; +export * from "./environment"; export * from "./ipc"; export * from "./terminal"; export * from "./provider"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 630ccd8249..4571f31dbe 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -105,8 +105,14 @@ export interface DesktopUpdateCheckResult { state: DesktopUpdateState; } +export interface DesktopEnvironmentBootstrap { + label: string; + wsUrl: string | null; +} + export interface DesktopBridge { getWsUrl: () => string | null; + getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6c7f073612..e6e4a52106 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,6 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; import { ClaudeModelOptions, CodexModelOptions } from "./model"; +import { RepositoryIdentity } from "./environment"; import { ApprovalRequestId, CheckpointRef, @@ -141,6 +142,7 @@ export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -647,6 +649,7 @@ export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -657,6 +660,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 776a0a89e9..9227f4d8c9 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import { ExecutionEnvironmentDescriptor } from "./environment"; import { IsoDateTime, NonNegativeInt, @@ -83,6 +84,7 @@ export const ServerObservability = Schema.Struct({ export type ServerObservability = typeof ServerObservability.Type; export const ServerConfig = Schema.Struct({ + environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, @@ -167,10 +169,12 @@ export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; export const ServerLifecycleReadyPayload = Schema.Struct({ at: IsoDateTime, + environment: ExecutionEnvironmentDescriptor, }); export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type; export const ServerLifecycleWelcomePayload = Schema.Struct({ + environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, projectName: TrimmedNonEmptyString, bootstrapProjectId: Schema.optional(ProjectId), diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 7beb7a75de..154acb0957 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -1,7 +1,11 @@ import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { applyGitStatusStreamEvent } from "./git"; +import { + applyGitStatusStreamEvent, + normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, +} from "./git"; describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { @@ -65,3 +69,28 @@ describe("applyGitStatusStreamEvent", () => { }); }); }); + +describe("normalizeGitRemoteUrl", () => { + it("canonicalizes equivalent GitHub remotes across protocol variants", () => { + expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe( + "github.com/t3tools/t3code", + ); + }); +}); + +describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { + it("extracts the owner and repository from common GitHub remote shapes", () => { + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + }); +}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 16171315b7..167be1e03d 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -80,6 +80,44 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { return branchName.slice(firstSeparatorIndex + 1); } +/** + * Normalize a git remote URL into a stable comparison key. + */ +export function normalizeGitRemoteUrl(value: string): string { + const normalized = value + .trim() + .replace(/\/+$/g, "") + .replace(/\.git$/i, "") + .toLowerCase(); + const hostAndPath = + /^(?:git@|ssh:\/\/git@|https:\/\/|http:\/\/|git:\/\/)([^/:]+)[:/]([^/\s]+\/[^/\s]+)$/i.exec( + normalized, + ); + + if (hostAndPath?.[1] && hostAndPath[2]) { + return `${hostAndPath[1]}/${hostAndPath[2]}`; + } + + return normalized; +} + +/** + * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes. + */ +export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { + const trimmed = url?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + + const match = + /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + trimmed, + ); + const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; + return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; +} + function deriveLocalBranchNameCandidatesFromRemoteRef( branchName: string, remoteName?: string, From d1437f1c9bbe56d9a2d0fef79c8de24ee726b50c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 14:25:00 -0700 Subject: [PATCH 02/14] Include client-runtime in release smoke checks - Add packages/client-runtime/package.json to the release smoke workspace list --- scripts/release-smoke.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index bf9d9f5c6a..98f7da5789 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -13,6 +13,7 @@ const workspaceFiles = [ "apps/desktop/package.json", "apps/web/package.json", "apps/marketing/package.json", + "packages/client-runtime/package.json", "packages/contracts/package.json", "packages/shared/package.json", "scripts/package.json", From deb215ef30017a08bfabe4912ebbbd36117e2c4d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 09:30:50 -0700 Subject: [PATCH 03/14] Treat explicit null environments distinctly - keep remote-host project/thread lookups separate from the active env - avoid double-enriching replayed project events --- .../Layers/ProjectionSnapshotQuery.ts | 1 - apps/server/src/server.test.ts | 73 ++++++++++++ apps/server/src/ws.ts | 63 +++++------ apps/web/src/components/Sidebar.tsx | 105 +++++++++++++++--- apps/web/src/store.test.ts | 56 ++++++++++ apps/web/src/store.ts | 57 ++++++++++ 6 files changed, 306 insertions(+), 49 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index ff44e20dbf..008415f210 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -748,7 +748,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", ), ), - Effect.map((option) => option), Effect.flatMap((option) => Option.isNone(option) ? Effect.succeed(Option.none()) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6762a89129..125cfd103a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2603,6 +2603,79 @@ 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 = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e493503c84..16e8531386 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -509,7 +509,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( 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; @@ -521,43 +524,33 @@ const WsRpcLayer = WsRpcGroup.toLayer( return source.pipe( Stream.mapEffect((event) => - enrichProjectEvent(event).pipe( - Effect.flatMap((enrichedEvent) => - Ref.modify( - state, - ({ - nextSequence, - pendingBySequence, - }): [Array, SequenceState] => { - if ( - enrichedEvent.sequence < nextSequence || - pendingBySequence.has(enrichedEvent.sequence) - ) { - return [[], { nextSequence, pendingBySequence }]; - } + Ref.modify( + state, + ({ + nextSequence, + pendingBySequence, + }): [Array, SequenceState] => { + if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { + return [[], { nextSequence, pendingBySequence }]; + } - const updatedPending = new Map(pendingBySequence); - updatedPending.set(enrichedEvent.sequence, enrichedEvent); + const updatedPending = new Map(pendingBySequence); + updatedPending.set(event.sequence, event); - const emit: Array = []; - let expected = nextSequence; - for (;;) { - const expectedEvent = updatedPending.get(expected); - if (!expectedEvent) { - break; - } - emit.push(expectedEvent); - updatedPending.delete(expected); - expected += 1; - } + const emit: Array = []; + let expected = nextSequence; + for (;;) { + const expectedEvent = updatedPending.get(expected); + if (!expectedEvent) { + break; + } + emit.push(expectedEvent); + updatedPending.delete(expected); + expected += 1; + } - return [ - emit, - { nextSequence: expected, pendingBySequence: updatedPending }, - ]; - }, - ), - ), + return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; + }, ), ), Stream.flatMap((events) => Stream.fromIterable(events)), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 923d9d88e8..9bc21cf8b3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -58,7 +58,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"; +<<<<<<< HEAD import { useStore } from "../store"; +======= +import { getProjectScopedId, useStore } from "../store"; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { @@ -128,7 +132,11 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; +<<<<<<< HEAD import type { Project } from "../types"; +======= +import type { Project, SidebarThreadSummary } from "../types"; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -255,8 +263,12 @@ function resolveThreadPr( } interface SidebarThreadRowProps { +<<<<<<< HEAD threadId: ThreadId; projectCwd: string | null; +======= + thread: SidebarThreadSummary; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) orderedProjectThreadIds: readonly ThreadId[]; routeThreadId: ThreadId | null; selectedThreadIds: ReadonlySet; @@ -279,7 +291,7 @@ interface SidebarThreadRowProps { navigateToThread: (threadId: ThreadId) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( - threadId: ThreadId, + thread: SidebarThreadSummary, position: { x: number; y: number }, ) => Promise; clearSelection: () => void; @@ -290,19 +302,20 @@ interface SidebarThreadRowProps { } function SidebarThreadRow(props: SidebarThreadRowProps) { +<<<<<<< HEAD const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); +======= + const { thread } = props; + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[thread.id]); +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const runningTerminalIds = useTerminalStateStore( (state) => - selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, + selectThreadTerminalState(state.terminalStateByThreadId, thread.id).runningTerminalIds, ); const gitCwd = thread?.worktreePath ?? props.projectCwd; const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); - if (!thread) { - return null; - } - const isActive = props.routeThreadId === thread.id; const isSelected = props.selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; @@ -369,7 +382,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { if (props.selectedThreadIds.size > 0) { props.clearSelection(); } - void props.handleThreadContextMenu(thread.id, { + void props.handleThreadContextMenu(thread, { x: event.clientX, y: event.clientY, }); @@ -678,10 +691,18 @@ function SortableProjectItem({ } export default function Sidebar() { +<<<<<<< HEAD const projectIds = useStore((store) => store.projectIds); const projectById = useStore((store) => store.projectById); const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById); const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); +======= + const projects = useStore((store) => store.projects); + const sidebarThreadsByScopedId = useStore((store) => store.sidebarThreadsByScopedId); + const threadScopedIdsByProjectScopedId = useStore( + (store) => store.threadScopedIdsByProjectScopedId, + ); +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ projectExpandedById: store.projectExpandedById, @@ -770,6 +791,27 @@ export default function Sidebar() { () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); +<<<<<<< HEAD +======= + const getProjectThreads = useCallback( + (project: Pick<(typeof projects)[number], "id" | "environmentId">) => + ( + threadScopedIdsByProjectScopedId[ + getProjectScopedId({ + environmentId: project.environmentId ?? null, + id: project.id, + }) + ] ?? [] + ) + .map((scopedId) => sidebarThreadsByScopedId[scopedId]) + .filter((thread): thread is NonNullable => thread !== undefined), + [sidebarThreadsByScopedId, threadScopedIdsByProjectScopedId], + ); + const sidebarThreadById = useMemo( + () => new Map(sidebarThreads.map((thread) => [thread.id, thread] as const)), + [sidebarThreads], + ); +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const routeTerminalOpen = routeThreadId ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen : false; @@ -1029,11 +1071,15 @@ export default function Sidebar() { }, }); const handleThreadContextMenu = useCallback( - async (threadId: ThreadId, position: { x: number; y: number }) => { + async (thread: SidebarThreadSummary, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; +<<<<<<< HEAD const thread = sidebarThreadsById[threadId]; if (!thread) return; +======= + const threadId = thread.id; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const threadWorkspacePath = thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( @@ -1094,8 +1140,12 @@ export default function Sidebar() { copyThreadIdToClipboard, deleteThread, markThreadUnread, +<<<<<<< HEAD projectCwdById, sidebarThreadsById, +======= + projectCwdByScopedId, +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1117,7 +1167,11 @@ export default function Sidebar() { if (clicked === "mark-unread") { for (const id of ids) { +<<<<<<< HEAD const thread = sidebarThreadsById[id]; +======= + const thread = sidebarThreadById.get(id); +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) markThreadUnread(id, thread?.latestTurn?.completedAt); } clearSelection(); @@ -1148,8 +1202,12 @@ export default function Sidebar() { deleteThread, markThreadUnread, removeFromSelection, + sidebarThreadById, selectedThreadIds, +<<<<<<< HEAD sidebarThreadsById, +======= +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1206,11 +1264,15 @@ export default function Sidebar() { ); const handleProjectContextMenu = useCallback( - async (projectId: ProjectId, position: { x: number; y: number }) => { + async (project: Project, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; +<<<<<<< HEAD const project = projects.find((entry) => entry.id === projectId); if (!project) return; +======= + const projectId = project.id; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const clicked = await api.contextMenu.show( [ @@ -1264,8 +1326,12 @@ export default function Sidebar() { clearProjectDraftThreadId, copyPathToClipboard, getDraftThreadByProjectId, +<<<<<<< HEAD projects, threadIdsByProjectId, +======= + getProjectThreads, +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1403,6 +1469,9 @@ export default function Sidebar() { hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ); const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); + const renderedThreads = pinnedCollapsedThread + ? [pinnedCollapsedThread] + : visibleProjectThreads; const renderedThreadIds = pinnedCollapsedThread ? [pinnedCollapsedThread.id] : visibleProjectThreads.map((thread) => thread.id); @@ -1414,6 +1483,7 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, + renderedThreads, renderedThreadIds, showEmptyThreadState, shouldShowThreadPanel, @@ -1559,7 +1629,7 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - renderedThreadIds, + renderedThreads, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -1581,7 +1651,7 @@ export default function Sidebar() { onContextMenu={(event) => { event.preventDefault(); suppressProjectClickForContextMenuRef.current = true; - void handleProjectContextMenu(project.id, { + void handleProjectContextMenu(project, { x: event.clientX, y: event.clientY, }); @@ -1687,16 +1757,21 @@ export default function Sidebar() { ) : null} {shouldShowThreadPanel && - renderedThreadIds.map((threadId) => ( + renderedThreads.map((thread) => ( >>>>>> 2540f09bb (Treat explicit null environments distinctly) orderedProjectThreadIds={orderedProjectThreadIds} routeThreadId={routeThreadId} selectedThreadIds={selectedThreadIds} showThreadJumpHints={showThreadJumpHints} - jumpLabel={threadJumpLabelById.get(threadId) ?? null} + jumpLabel={threadJumpLabelById.get(thread.id) ?? null} appSettingsConfirmThreadArchive={appSettings.confirmThreadArchive} renamingThreadId={renamingThreadId} renamingTitle={renamingTitle} @@ -1715,6 +1790,10 @@ export default function Sidebar() { cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} +<<<<<<< HEAD +======= + pr={prByThreadId.get(thread.id) ?? null} +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) /> ))} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 05128905f0..ff049636aa 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -14,8 +14,14 @@ import { describe, expect, it } from "vitest"; import { applyOrchestrationEvent, applyOrchestrationEvents, +<<<<<<< HEAD selectProjects, selectThreads, +======= + getProjectScopedId, + getThreadScopedId, + selectThreadIdsByProjectId, +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) syncServerReadModel, type AppState, } from "./store"; @@ -406,6 +412,56 @@ describe("store read model sync", () => { expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); }); + + it("treats an explicit null environment as distinct from the active environment", () => { + const remoteThread = makeThread({ + id: ThreadId.makeUnsafe("thread-remote"), + projectId: ProjectId.makeUnsafe("project-remote"), + environmentId: remoteEnvironmentId, + title: "Remote thread", + }); + const initialState: AppState = { + ...makeState(remoteThread), + activeEnvironmentId: remoteEnvironmentId, + }; + + const next = syncServerReadModel( + initialState, + makeReadModel( + makeReadModelThread({ + id: ThreadId.makeUnsafe("thread-null-environment"), + title: "Null environment thread", + }), + ), + null, + ); + + expect(next.threads).toHaveLength(2); + expect(next.threads.find((thread) => thread.environmentId === remoteEnvironmentId)?.title).toBe( + "Remote thread", + ); + expect(next.threads.find((thread) => thread.environmentId === null)?.title).toBe( + "Null environment thread", + ); + }); + + it("returns a stable thread id array for unchanged project thread inputs", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + const syncedState = syncServerReadModel( + makeState(makeThread()), + makeReadModel(makeReadModelThread({ projectId })), + localEnvironmentId, + ); + const selectThreadIds = selectThreadIdsByProjectId(projectId); + + const first = selectThreadIds(syncedState); + const second = selectThreadIds({ + ...syncedState, + bootstrapComplete: false, + }); + + expect(first).toBe(second); + }); }); describe("incremental orchestration updates", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 4fbb11942c..a70acfa89a 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -947,6 +947,7 @@ function buildThreadState( const turnDiffSummaryByThreadId: Record> = {}; const sidebarThreadSummaryById: Record = {}; +<<<<<<< HEAD for (const thread of threads) { threadIds.push(thread.id); threadIdsByProjectId[thread.projectId] = [ @@ -987,6 +988,13 @@ function buildThreadState( turnDiffSummaryByThreadId, sidebarThreadSummaryById, }; +======= +function resolveTargetEnvironmentId( + state: AppState, + environmentId?: EnvironmentId | null, +): EnvironmentId | null { + return environmentId !== undefined ? environmentId : (state.activeEnvironmentId ?? null); +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) } export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { @@ -1501,11 +1509,60 @@ export const selectThreadById = export const selectSidebarThreadSummaryById = (threadId: ThreadId | null | undefined) => (state: AppState): SidebarThreadSummary | undefined => +<<<<<<< HEAD threadId ? state.sidebarThreadSummaryById[threadId] : undefined; export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => (state: AppState): ThreadId[] => projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; +======= + threadId + ? state.sidebarThreadsByScopedId[ + getThreadScopedId({ + environmentId: state.activeEnvironmentId, + id: threadId, + }) + ] + : undefined; + +export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => { + let cachedProjectScopedId: string | null = null; + let cachedScopedIds: string[] | undefined; + let cachedSidebarThreadsByScopedId: Record | undefined; + let cachedResult: ThreadId[] = EMPTY_THREAD_IDS; + + return (state: AppState): ThreadId[] => { + if (!projectId) { + return EMPTY_THREAD_IDS; + } + + const projectScopedId = getProjectScopedId({ + environmentId: state.activeEnvironmentId, + id: projectId, + }); + const scopedIds = state.threadScopedIdsByProjectScopedId[projectScopedId] ?? EMPTY_SCOPED_IDS; + const sidebarThreadsByScopedId = state.sidebarThreadsByScopedId; + + if ( + cachedProjectScopedId === projectScopedId && + cachedScopedIds === scopedIds && + cachedSidebarThreadsByScopedId === sidebarThreadsByScopedId + ) { + return cachedResult; + } + + const result = scopedIds + .map((scopedId) => sidebarThreadsByScopedId[scopedId]?.id ?? null) + .filter((threadId): threadId is ThreadId => threadId !== null); + + cachedProjectScopedId = projectScopedId; + cachedScopedIds = scopedIds; + cachedSidebarThreadsByScopedId = sidebarThreadsByScopedId; + cachedResult = result; + return result; + }; +}; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { return updateThreadState(state, threadId, (thread) => { From a16ee82f11adde1ee6d14521cb18d64585a9ca7c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 10:39:06 -0700 Subject: [PATCH 04/14] Support nested repo remotes and late URL resolution - Preserve GitLab subgroup paths when normalizing remotes - Re-resolve repository identity after remotes are added - Prefer the bootstrap URL when resolving the web socket server --- .../Layers/RepositoryIdentityResolver.test.ts | 43 +++++++ .../Layers/RepositoryIdentityResolver.ts | 108 ++++++++++-------- apps/web/src/lib/utils.test.ts | 24 +++- apps/web/src/lib/utils.ts | 7 +- packages/shared/src/git.test.ts | 68 +++++++---- packages/shared/src/git.ts | 2 +- 6 files changed, 170 insertions(+), 82 deletions(-) diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 8b1f4ce528..2992f17178 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -73,4 +73,47 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { 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("re-resolves after a remote is configured later in the same process", () => + 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 resolvedIdentity = yield* resolver.resolve(cwd); + expect(resolvedIdentity).not.toBeNull(); + expect(resolvedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(resolvedIdentity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); }); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 9d94518afe..4e33f5c162 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -1,11 +1,8 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; -import { Effect, Layer, Ref } from "effect"; -import { runProcess } from "../../processRunner.ts"; -import { - normalizeGitRemoteUrl, - parseGitHubRepositoryNameWithOwnerFromRemoteUrl, -} from "@t3tools/shared/git"; +import { Cache, Duration, Effect, Exit, Layer } from "effect"; +import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { runProcess } from "../../processRunner.ts"; import { RepositoryIdentityResolver, type RepositoryIdentityResolverShape, @@ -47,8 +44,11 @@ function buildRepositoryIdentity(input: { readonly remoteUrl: string; }): RepositoryIdentity { const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); - const githubNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(input.remoteUrl); - const [owner, repositoryName] = githubNameWithOwner?.split("/") ?? []; + 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, @@ -57,81 +57,91 @@ function buildRepositoryIdentity(input: { remoteName: input.remoteName, remoteUrl: input.remoteUrl, }, - ...(githubNameWithOwner ? { displayName: githubNameWithOwner } : {}), - ...(githubNameWithOwner ? { provider: "github" } : {}), + ...(repositoryPath ? { displayName: repositoryPath } : {}), + ...(hostingProvider ? { provider: hostingProvider.kind } : {}), ...(owner ? { owner } : {}), ...(repositoryName ? { name: repositoryName } : {}), }; } -async function resolveRepositoryIdentity(cwd: string): Promise<{ - readonly cacheKey: string; - readonly identity: RepositoryIdentity | null; -}> { - let topLevel = cwd; +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: cwd, identity: null }; + return cacheKey; } const candidate = topLevelResult.stdout.trim(); if (candidate.length > 0) { - topLevel = candidate; + cacheKey = candidate; } } catch { - return { cacheKey: cwd, identity: null }; + return cacheKey; } + return cacheKey; +} + +async function resolveRepositoryIdentityFromCacheKey( + cacheKey: string, +): Promise { try { - const remoteResult = await runProcess("git", ["-C", topLevel, "remote", "-v"], { + const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], { allowNonZeroExit: true, }); if (remoteResult.code !== 0) { - return { cacheKey: topLevel, identity: null }; + return null; } const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); - return { - cacheKey: topLevel, - identity: remote ? buildRepositoryIdentity(remote) : null, - }; + return remote ? buildRepositoryIdentity(remote) : null; } catch { - return { cacheKey: topLevel, identity: null }; + return null; } } -export const makeRepositoryIdentityResolver = Effect.gen(function* () { - const cacheRef = yield* Ref.make(new Map()); - - const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( - "RepositoryIdentityResolver.resolve", - )(function* (cwd) { - const cache = yield* Ref.get(cacheRef); - const cached = cache.get(cwd); - if (cached !== undefined) { - return cached; - } +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 resolved = yield* Effect.promise(() => resolveRepositoryIdentity(cwd)); - yield* Ref.update(cacheRef, (current) => { - const next = new Map(current); - next.set(cwd, resolved.identity); - next.set(resolved.cacheKey, resolved.identity); - return next; + const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( + "RepositoryIdentityResolver.resolve", + )(function* (cwd) { + const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd)); + return yield* Cache.get(repositoryIdentityCache, cacheKey); }); - return resolved.identity; - }); - return { - resolve, - } satisfies RepositoryIdentityResolverShape; -}); + return { + resolve, + } satisfies RepositoryIdentityResolverShape; + }, +); export const RepositoryIdentityResolverLive = Layer.effect( RepositoryIdentityResolver, - makeRepositoryIdentityResolver, + makeRepositoryIdentityResolver(), ); diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 017b6bee07..92cb092392 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -1,6 +1,11 @@ -import { assert, describe, it } from "vitest"; +import { assert, describe, expect, it, vi } from "vitest"; + +vi.mock("../environmentBootstrap", () => ({ + resolvePrimaryEnvironmentBootstrapUrl: vi.fn(() => "http://bootstrap.test:4321"), +})); import { isWindowsPlatform } from "./utils"; +import { resolveServerUrl } from "./utils"; describe("isWindowsPlatform", () => { it("matches Windows platform identifiers", () => { @@ -13,3 +18,20 @@ describe("isWindowsPlatform", () => { assert.isFalse(isWindowsPlatform("darwin")); }); }); + +describe("resolveServerUrl", () => { + it("uses the bootstrap environment URL when no explicit URL is provided", () => { + expect(resolveServerUrl()).toBe("http://bootstrap.test:4321/"); + }); + + it("prefers an explicit URL override", () => { + expect( + resolveServerUrl({ + url: "https://override.test:9999", + protocol: "wss", + pathname: "/rpc", + searchParams: { hello: "world" }, + }), + ).toBe("wss://override.test:9999/rpc?hello=world"); + }); +}); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 5b0bcec4bd..7c3b5e4590 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -53,12 +53,7 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString( - options?.url, - resolvePrimaryEnvironmentBootstrapUrl(), - import.meta.env.VITE_WS_URL, - window.location.origin, - ); + const rawUrl = firstNonEmptyString(options?.url, resolvePrimaryEnvironmentBootstrapUrl()); const parsedUrl = new URL(rawUrl); if (options?.protocol) { diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 154acb0957..dac644e83b 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -7,6 +7,49 @@ import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl, } from "./git"; +describe("normalizeGitRemoteUrl", () => { + it("canonicalizes equivalent GitHub remotes across protocol variants", () => { + expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe( + "github.com/t3tools/t3code", + ); + }); + + it("preserves nested group paths for providers like GitLab", () => { + expect(normalizeGitRemoteUrl("git@gitlab.com:T3Tools/platform/T3Code.git")).toBe( + "gitlab.com/t3tools/platform/t3code", + ); + expect(normalizeGitRemoteUrl("https://gitlab.com/T3Tools/platform/T3Code.git")).toBe( + "gitlab.com/t3tools/platform/t3code", + ); + }); + + it("drops explicit ports from URL-shaped remotes", () => { + expect(normalizeGitRemoteUrl("https://gitlab.company.com:8443/team/project.git")).toBe( + "gitlab.company.com/team/project", + ); + expect(normalizeGitRemoteUrl("ssh://git@gitlab.company.com:2222/team/project.git")).toBe( + "gitlab.company.com/team/project", + ); + }); +}); + +describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { + it("extracts the owner and repository from common GitHub remote shapes", () => { + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + }); +}); + describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { const remote: GitStatusRemoteResult = { @@ -69,28 +112,3 @@ describe("applyGitStatusStreamEvent", () => { }); }); }); - -describe("normalizeGitRemoteUrl", () => { - it("canonicalizes equivalent GitHub remotes across protocol variants", () => { - expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( - "github.com/t3tools/t3code", - ); - expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe( - "github.com/t3tools/t3code", - ); - expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe( - "github.com/t3tools/t3code", - ); - }); -}); - -describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { - it("extracts the owner and repository from common GitHub remote shapes", () => { - expect( - parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), - ).toBe("T3Tools/T3Code"); - expect( - parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), - ).toBe("T3Tools/T3Code"); - }); -}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 167be1e03d..55871f8ed5 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -90,7 +90,7 @@ export function normalizeGitRemoteUrl(value: string): string { .replace(/\.git$/i, "") .toLowerCase(); const hostAndPath = - /^(?:git@|ssh:\/\/git@|https:\/\/|http:\/\/|git:\/\/)([^/:]+)[:/]([^/\s]+\/[^/\s]+)$/i.exec( + /^(?:git@|ssh:\/\/git@|https:\/\/|http:\/\/|git:\/\/)([^/:]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec( normalized, ); From 40ab9d9295a568a923bcc9be919af633f08eeddf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 11:25:39 -0700 Subject: [PATCH 05/14] Require environment IDs in web state sync - Make active environment mandatory in store and read-model sync - Scope chat, sidebar, and event replay state by resolved environment - Update tests for environment-aware thread and project handling --- .../web/src/components/ChatView.logic.test.ts | 8 +- apps/web/src/components/ChatView.logic.ts | 10 +- apps/web/src/components/ChatView.tsx | 12 +- apps/web/src/components/Sidebar.logic.test.ts | 6 +- apps/web/src/components/Sidebar.tsx | 105 +------ apps/web/src/routes/__root.tsx | 84 ++++- apps/web/src/store.test.ts | 98 +++--- apps/web/src/store.ts | 294 ++++++++++++------ apps/web/src/types.ts | 7 +- apps/web/src/worktreeCleanup.test.ts | 5 +- 10 files changed, 361 insertions(+), 268 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cad565247d..03bc9ed9f5 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,4 @@ -import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useStore } from "../store"; import { type Thread } from "../types"; @@ -13,6 +13,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 +183,7 @@ const makeThread = (input?: { } | null; }): Thread => ({ id: input?.id ?? ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", @@ -414,6 +417,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 +454,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 +500,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..3818613fbe 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,10 @@ -import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { + type EnvironmentId, + ProjectId, + type ModelSelection, + 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"; @@ -18,12 +24,14 @@ export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema. export function buildLocalDraftThread( threadId: ThreadId, + environmentId: EnvironmentId, draftThread: DraftThreadState, fallbackModelSelection: ModelSelection, error: string | null, ): Thread { return { id: threadId, + environmentId, codexThreadId: null, projectId: draftThread.projectId, title: "New thread", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index da5c87cbfc..f6bdcf1117 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -775,12 +775,14 @@ export default function ChatView({ threadId }: ChatViewProps) { const fallbackDraftProject = useStore((state) => draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined, ); + const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => - draftThread + draftThread && activeEnvironmentId ? buildLocalDraftThread( threadId, + activeEnvironmentId, draftThread, fallbackDraftProject?.defaultModelSelection ?? { provider: "codex", @@ -789,7 +791,13 @@ export default function ChatView({ threadId }: ChatViewProps) { localDraftError, ) : undefined, - [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], + [ + activeEnvironmentId, + draftThread, + fallbackDraftProject?.defaultModelSelection, + localDraftError, + threadId, + ], ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = 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 9bc21cf8b3..923d9d88e8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -58,11 +58,7 @@ 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"; -<<<<<<< HEAD import { useStore } from "../store"; -======= -import { getProjectScopedId, useStore } from "../store"; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { @@ -132,11 +128,7 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -<<<<<<< HEAD import type { Project } from "../types"; -======= -import type { Project, SidebarThreadSummary } from "../types"; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -263,12 +255,8 @@ function resolveThreadPr( } interface SidebarThreadRowProps { -<<<<<<< HEAD threadId: ThreadId; projectCwd: string | null; -======= - thread: SidebarThreadSummary; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) orderedProjectThreadIds: readonly ThreadId[]; routeThreadId: ThreadId | null; selectedThreadIds: ReadonlySet; @@ -291,7 +279,7 @@ interface SidebarThreadRowProps { navigateToThread: (threadId: ThreadId) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( - thread: SidebarThreadSummary, + threadId: ThreadId, position: { x: number; y: number }, ) => Promise; clearSelection: () => void; @@ -302,20 +290,19 @@ interface SidebarThreadRowProps { } function SidebarThreadRow(props: SidebarThreadRowProps) { -<<<<<<< HEAD const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); -======= - const { thread } = props; - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[thread.id]); ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const runningTerminalIds = useTerminalStateStore( (state) => - selectThreadTerminalState(state.terminalStateByThreadId, thread.id).runningTerminalIds, + selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, ); const gitCwd = thread?.worktreePath ?? props.projectCwd; const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); + if (!thread) { + return null; + } + const isActive = props.routeThreadId === thread.id; const isSelected = props.selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; @@ -382,7 +369,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { if (props.selectedThreadIds.size > 0) { props.clearSelection(); } - void props.handleThreadContextMenu(thread, { + void props.handleThreadContextMenu(thread.id, { x: event.clientX, y: event.clientY, }); @@ -691,18 +678,10 @@ function SortableProjectItem({ } export default function Sidebar() { -<<<<<<< HEAD const projectIds = useStore((store) => store.projectIds); const projectById = useStore((store) => store.projectById); const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById); const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); -======= - const projects = useStore((store) => store.projects); - const sidebarThreadsByScopedId = useStore((store) => store.sidebarThreadsByScopedId); - const threadScopedIdsByProjectScopedId = useStore( - (store) => store.threadScopedIdsByProjectScopedId, - ); ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ projectExpandedById: store.projectExpandedById, @@ -791,27 +770,6 @@ export default function Sidebar() { () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); -<<<<<<< HEAD -======= - const getProjectThreads = useCallback( - (project: Pick<(typeof projects)[number], "id" | "environmentId">) => - ( - threadScopedIdsByProjectScopedId[ - getProjectScopedId({ - environmentId: project.environmentId ?? null, - id: project.id, - }) - ] ?? [] - ) - .map((scopedId) => sidebarThreadsByScopedId[scopedId]) - .filter((thread): thread is NonNullable => thread !== undefined), - [sidebarThreadsByScopedId, threadScopedIdsByProjectScopedId], - ); - const sidebarThreadById = useMemo( - () => new Map(sidebarThreads.map((thread) => [thread.id, thread] as const)), - [sidebarThreads], - ); ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const routeTerminalOpen = routeThreadId ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen : false; @@ -1071,15 +1029,11 @@ export default function Sidebar() { }, }); const handleThreadContextMenu = useCallback( - async (thread: SidebarThreadSummary, position: { x: number; y: number }) => { + async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; -<<<<<<< HEAD const thread = sidebarThreadsById[threadId]; if (!thread) return; -======= - const threadId = thread.id; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const threadWorkspacePath = thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( @@ -1140,12 +1094,8 @@ export default function Sidebar() { copyThreadIdToClipboard, deleteThread, markThreadUnread, -<<<<<<< HEAD projectCwdById, sidebarThreadsById, -======= - projectCwdByScopedId, ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1167,11 +1117,7 @@ export default function Sidebar() { if (clicked === "mark-unread") { for (const id of ids) { -<<<<<<< HEAD const thread = sidebarThreadsById[id]; -======= - const thread = sidebarThreadById.get(id); ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) markThreadUnread(id, thread?.latestTurn?.completedAt); } clearSelection(); @@ -1202,12 +1148,8 @@ export default function Sidebar() { deleteThread, markThreadUnread, removeFromSelection, - sidebarThreadById, selectedThreadIds, -<<<<<<< HEAD sidebarThreadsById, -======= ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1264,15 +1206,11 @@ export default function Sidebar() { ); const handleProjectContextMenu = useCallback( - async (project: Project, position: { x: number; y: number }) => { + async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; -<<<<<<< HEAD const project = projects.find((entry) => entry.id === projectId); if (!project) return; -======= - const projectId = project.id; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const clicked = await api.contextMenu.show( [ @@ -1326,12 +1264,8 @@ export default function Sidebar() { clearProjectDraftThreadId, copyPathToClipboard, getDraftThreadByProjectId, -<<<<<<< HEAD projects, threadIdsByProjectId, -======= - getProjectThreads, ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1469,9 +1403,6 @@ export default function Sidebar() { hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ); const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreads = pinnedCollapsedThread - ? [pinnedCollapsedThread] - : visibleProjectThreads; const renderedThreadIds = pinnedCollapsedThread ? [pinnedCollapsedThread.id] : visibleProjectThreads.map((thread) => thread.id); @@ -1483,7 +1414,6 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - renderedThreads, renderedThreadIds, showEmptyThreadState, shouldShowThreadPanel, @@ -1629,7 +1559,7 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - renderedThreads, + renderedThreadIds, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -1651,7 +1581,7 @@ export default function Sidebar() { onContextMenu={(event) => { event.preventDefault(); suppressProjectClickForContextMenuRef.current = true; - void handleProjectContextMenu(project, { + void handleProjectContextMenu(project.id, { x: event.clientX, y: event.clientY, }); @@ -1757,21 +1687,16 @@ export default function Sidebar() { ) : null} {shouldShowThreadPanel && - renderedThreads.map((thread) => ( + renderedThreadIds.map((threadId) => ( >>>>>> 2540f09bb (Treat explicit null environments distinctly) orderedProjectThreadIds={orderedProjectThreadIds} routeThreadId={routeThreadId} selectedThreadIds={selectedThreadIds} showThreadJumpHints={showThreadJumpHints} - jumpLabel={threadJumpLabelById.get(thread.id) ?? null} + jumpLabel={threadJumpLabelById.get(threadId) ?? null} appSettingsConfirmThreadArchive={appSettings.confirmThreadArchive} renamingThreadId={renamingThreadId} renamingTitle={renamingTitle} @@ -1790,10 +1715,6 @@ export default function Sidebar() { cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} -<<<<<<< HEAD -======= - pr={prByThreadId.get(thread.id) ?? null} ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) /> ))} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 48c835ae79..4cee742092 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,5 @@ import { + type EnvironmentId, OrchestrationEvent, type ServerLifecycleWelcomePayload, ThreadId, @@ -201,6 +202,13 @@ function coalesceOrchestrationUiEvents( const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; +function resolveKnownEnvironmentId(input: { + serverConfigEnvironmentId: EnvironmentId | null | undefined; + activeEnvironmentId: EnvironmentId | null; +}): EnvironmentId | null { + return input.serverConfigEnvironmentId ?? input.activeEnvironmentId; +} + function ServerStateBootstrap() { useEffect(() => startServerStateSync(getWsRpcClient().server), []); @@ -209,6 +217,7 @@ function ServerStateBootstrap() { function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); + const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); const syncProjects = useUiStateStore((store) => store.syncProjects); @@ -226,15 +235,26 @@ function EventRouter() { const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); const disposedRef = useRef(false); - const bootstrapFromSnapshotRef = useRef<() => Promise>(async () => undefined); + const bootstrapFromSnapshotRef = useRef<(environmentId: EnvironmentId) => Promise>( + async () => undefined, + ); + const schedulePendingDomainEventFlushRef = useRef<() => void>(() => undefined); const serverConfig = useServerConfig(); + const resolveCurrentEnvironmentId = useEffectEvent((): EnvironmentId | null => + resolveKnownEnvironmentId({ + serverConfigEnvironmentId: serverConfig?.environment.environmentId, + activeEnvironmentId: useStore.getState().activeEnvironmentId, + }), + ); const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { if (!payload) return; + setActiveEnvironmentId(payload.environment.environmentId); + schedulePendingDomainEventFlushRef.current(); migrateLocalSettingsToServer(); void (async () => { - await bootstrapFromSnapshotRef.current(); + await bootstrapFromSnapshotRef.current(payload.environment.environmentId); if (disposedRef.current) { return; } @@ -316,6 +336,15 @@ function EventRouter() { }, ); + useEffect(() => { + if (!serverConfig) { + return; + } + + setActiveEnvironmentId(serverConfig.environment.environmentId); + schedulePendingDomainEventFlushRef.current(); + }, [serverConfig, setActiveEnvironmentId]); + useEffect(() => { const api = readNativeApi(); if (!api) return; @@ -371,7 +400,10 @@ function EventRouter() { }, ); - const applyEventBatch = (events: ReadonlyArray) => { + const applyEventBatch = ( + events: ReadonlyArray, + environmentId: EnvironmentId, + ) => { const nextEvents = recovery.markEventBatchApplied(events); if (nextEvents.length === 0) { return; @@ -391,7 +423,7 @@ function EventRouter() { void queryInvalidationThrottler.maybeExecute(); } - applyOrchestrationEvents(uiEvents); + applyOrchestrationEvents(uiEvents, environmentId); if (needsProjectUiSync) { const projects = selectProjects(useStore.getState()); syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); @@ -425,9 +457,13 @@ function EventRouter() { if (disposed || pendingDomainEvents.length === 0) { return; } + const currentEnvironmentId = resolveCurrentEnvironmentId(); + if (currentEnvironmentId === null) { + return; + } const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); - applyEventBatch(events); + applyEventBatch(events, currentEnvironmentId); }; const schedulePendingDomainEventFlush = () => { if (flushPendingDomainEventsScheduled) { @@ -437,6 +473,7 @@ function EventRouter() { flushPendingDomainEventsScheduled = true; queueMicrotask(flushPendingDomainEvents); }; + schedulePendingDomainEventFlushRef.current = schedulePendingDomainEventFlush; const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise => { if (!recovery.beginReplayRecovery(reason)) { @@ -447,7 +484,13 @@ function EventRouter() { try { const events = await api.orchestration.replayEvents(fromSequenceExclusive); if (!disposed) { - applyEventBatch(events); + const currentEnvironmentId = resolveCurrentEnvironmentId(); + if (currentEnvironmentId === null) { + replayRetryTracker = null; + recovery.failReplayRecovery(); + return; + } + applyEventBatch(events, currentEnvironmentId); } } catch { replayRetryTracker = null; @@ -489,7 +532,10 @@ function EventRouter() { } }; - const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise => { + const runSnapshotRecovery = async ( + reason: "bootstrap" | "replay-failed", + environmentId: EnvironmentId, + ): Promise => { const started = recovery.beginSnapshotRecovery(reason); if (import.meta.env.MODE !== "test") { const state = recovery.getState(); @@ -512,7 +558,7 @@ function EventRouter() { try { const snapshot = await api.orchestration.getSnapshot(); if (!disposed) { - syncServerReadModel(snapshot); + syncServerReadModel(snapshot, environmentId); reconcileSnapshotDerivedState(); if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { void runReplayRecovery("sequence-gap"); @@ -524,13 +570,17 @@ function EventRouter() { } }; - const bootstrapFromSnapshot = async (): Promise => { - await runSnapshotRecovery("bootstrap"); + const bootstrapFromSnapshot = async (environmentId: EnvironmentId): Promise => { + await runSnapshotRecovery("bootstrap", environmentId); }; bootstrapFromSnapshotRef.current = bootstrapFromSnapshot; const fallbackToSnapshotRecovery = async (): Promise => { - await runSnapshotRecovery("replay-failed"); + const currentEnvironmentId = resolveCurrentEnvironmentId(); + if (currentEnvironmentId === null) { + return; + } + await runSnapshotRecovery("replay-failed", currentEnvironmentId); }; const unsubDomainEvent = api.orchestration.onDomainEvent( (event) => { @@ -556,8 +606,16 @@ function EventRouter() { }, ); const unsubTerminalEvent = api.terminal.onEvent((event) => { + const currentEnvironmentId = resolveCurrentEnvironmentId(); + if (currentEnvironmentId === null) { + return; + } const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); - if (thread && thread.archivedAt !== null) { + if ( + thread && + thread.environmentId === currentEnvironmentId && + thread.archivedAt !== null + ) { return; } applyTerminalEvent(event); @@ -568,6 +626,7 @@ function EventRouter() { needsProviderInvalidation = false; flushPendingDomainEventsScheduled = false; pendingDomainEvents.length = 0; + schedulePendingDomainEventFlushRef.current = () => undefined; queryInvalidationThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); @@ -581,6 +640,7 @@ function EventRouter() { applyTerminalEvent, clearThreadUi, setProjectExpanded, + setActiveEnvironmentId, syncProjects, syncServerReadModel, syncThreads, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index ff049636aa..2fef45fed6 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,6 +1,7 @@ import { CheckpointRef, DEFAULT_MODEL_BY_PROVIDER, + EnvironmentId, EventId, MessageId, ProjectId, @@ -14,22 +15,48 @@ import { describe, expect, it } from "vitest"; import { applyOrchestrationEvent, applyOrchestrationEvents, -<<<<<<< HEAD selectProjects, selectThreads, -======= - getProjectScopedId, - getThreadScopedId, - selectThreadIdsByProjectId, ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) syncServerReadModel, type AppState, } from "./store"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + +function withActiveEnvironmentState( + environmentState: Omit, + overrides: Partial = {}, +): AppState { + const { + activeEnvironmentId: overrideActiveEnvironmentId, + environmentStateById: overrideEnvironmentStateById, + ...environmentOverrides + } = overrides; + const activeEnvironmentId = overrideActiveEnvironmentId ?? localEnvironmentId; + const mergedEnvironmentState = { + ...environmentState, + ...environmentOverrides, + }; + const environmentStateById = + overrideEnvironmentStateById ?? + (activeEnvironmentId + ? { + [activeEnvironmentId]: mergedEnvironmentState, + } + : {}); + + return { + activeEnvironmentId, + environmentStateById, + ...mergedEnvironmentState, + }; +} + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", @@ -58,6 +85,7 @@ function makeState(thread: Thread): AppState { const projectId = ProjectId.makeUnsafe("project-1"); const project = { id: projectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -71,7 +99,7 @@ function makeState(thread: Thread): AppState { const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { [thread.projectId]: [thread.id], }; - return { + const environmentState = { projectIds: [projectId], projectById: { [projectId]: project, @@ -81,6 +109,7 @@ function makeState(thread: Thread): AppState { threadShellById: { [thread.id]: { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -141,10 +170,11 @@ function makeState(thread: Thread): AppState { sidebarThreadSummaryById: {}, bootstrapComplete: true, }; + return withActiveEnvironmentState(environmentState); } function makeEmptyState(overrides: Partial = {}): AppState { - return { + const environmentState = { projectIds: [], projectById: {}, threadIds: [], @@ -162,8 +192,8 @@ function makeEmptyState(overrides: Partial = {}): AppState { turnDiffSummaryByThreadId: {}, sidebarThreadSummaryById: {}, bootstrapComplete: true, - ...overrides, }; + return withActiveEnvironmentState(environmentState, overrides); } function projectsOf(state: AppState) { @@ -412,56 +442,6 @@ describe("store read model sync", () => { expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); }); - - it("treats an explicit null environment as distinct from the active environment", () => { - const remoteThread = makeThread({ - id: ThreadId.makeUnsafe("thread-remote"), - projectId: ProjectId.makeUnsafe("project-remote"), - environmentId: remoteEnvironmentId, - title: "Remote thread", - }); - const initialState: AppState = { - ...makeState(remoteThread), - activeEnvironmentId: remoteEnvironmentId, - }; - - const next = syncServerReadModel( - initialState, - makeReadModel( - makeReadModelThread({ - id: ThreadId.makeUnsafe("thread-null-environment"), - title: "Null environment thread", - }), - ), - null, - ); - - expect(next.threads).toHaveLength(2); - expect(next.threads.find((thread) => thread.environmentId === remoteEnvironmentId)?.title).toBe( - "Remote thread", - ); - expect(next.threads.find((thread) => thread.environmentId === null)?.title).toBe( - "Null environment thread", - ); - }); - - it("returns a stable thread id array for unchanged project thread inputs", () => { - const projectId = ProjectId.makeUnsafe("project-1"); - const syncedState = syncServerReadModel( - makeState(makeThread()), - makeReadModel(makeReadModelThread({ projectId })), - localEnvironmentId, - ); - const selectThreadIds = selectThreadIdsByProjectId(projectId); - - const first = selectThreadIds(syncedState); - const second = selectThreadIds({ - ...syncedState, - bootstrapComplete: false, - }); - - expect(first).toBe(second); - }); }); describe("incremental orchestration updates", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index a70acfa89a..54429f9c15 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,4 +1,5 @@ import { + type EnvironmentId, type MessageId, type OrchestrationCheckpointSummary, type OrchestrationEvent, @@ -35,7 +36,7 @@ import { } from "./types"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; -export interface AppState { +export interface EnvironmentState { projectIds: ProjectId[]; projectById: Record; threadIds: ThreadId[]; @@ -55,7 +56,12 @@ export interface AppState { bootstrapComplete: boolean; } -const initialState: AppState = { +export interface AppState extends EnvironmentState { + activeEnvironmentId: EnvironmentId | null; + environmentStateById: Record; +} + +const initialEnvironmentState: EnvironmentState = { projectIds: [], projectById: {}, threadIds: [], @@ -75,6 +81,12 @@ const initialState: AppState = { bootstrapComplete: false, }; +const initialState: AppState = { + activeEnvironmentId: null, + environmentStateById: {}, + ...initialEnvironmentState, +}; + const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; const MAX_THREAD_PROPOSED_PLANS = 200; @@ -169,9 +181,13 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif }; } -function mapProject(project: OrchestrationReadModel["projects"][number]): Project { +function mapProject( + project: OrchestrationReadModel["projects"][number], + environmentId: EnvironmentId, +): Project { return { id: project.id, + environmentId, name: project.title, cwd: project.workspaceRoot, defaultModelSelection: project.defaultModelSelection @@ -183,9 +199,10 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec }; } -function mapThread(thread: OrchestrationThread): Thread { +function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread { return { id: thread.id, + environmentId, codexThreadId: null, projectId: thread.projectId, title: thread.title, @@ -211,6 +228,7 @@ function mapThread(thread: OrchestrationThread): Thread { function toThreadShell(thread: Thread): ThreadShell { return { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -251,6 +269,7 @@ function getLatestUserMessageAt(messages: ReadonlyArray): string | function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { return { id: thread.id, + environmentId: thread.environmentId, projectId: thread.projectId, title: thread.title, interactionMode: thread.interactionMode, @@ -298,6 +317,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b return ( left !== undefined && left.id === right.id && + left.environmentId === right.environmentId && left.codexThreadId === right.codexThreadId && left.projectId === right.projectId && left.title === right.title && @@ -377,7 +397,7 @@ function buildTurnDiffSlice(thread: Thread): { }; } -function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] { +function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] { const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; if (ids.length === 0) { @@ -390,7 +410,7 @@ function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[ } function selectThreadActivities( - state: AppState, + state: EnvironmentState, threadId: ThreadId, ): OrchestrationThreadActivity[] { const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; @@ -404,7 +424,7 @@ function selectThreadActivities( }); } -function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] { +function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] { const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; if (ids.length === 0) { @@ -416,7 +436,10 @@ function selectThreadProposedPlans(state: AppState, threadId: ThreadId): Propose }); } -function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] { +function selectThreadTurnDiffSummaries( + state: EnvironmentState, + threadId: ThreadId, +): TurnDiffSummary[] { const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; if (ids.length === 0) { @@ -428,7 +451,7 @@ function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): Tur }); } -function getThread(state: AppState, threadId: ThreadId): Thread | undefined { +function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined { const shell = state.threadShellById[threadId]; if (!shell) { return undefined; @@ -446,21 +469,25 @@ function getThread(state: AppState, threadId: ThreadId): Thread | undefined { }; } -function getProjects(state: AppState): Project[] { +function getProjects(state: EnvironmentState): Project[] { return state.projectIds.flatMap((projectId) => { const project = state.projectById[projectId]; return project ? [project] : []; }); } -function getThreads(state: AppState): Thread[] { +function getThreads(state: EnvironmentState): Thread[] { return state.threadIds.flatMap((threadId) => { const thread = getThread(state, threadId); return thread ? [thread] : []; }); } -function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState { +function writeThreadState( + state: EnvironmentState, + nextThread: Thread, + previousThread?: Thread, +): EnvironmentState { const nextShell = toThreadShell(nextThread); const nextTurnState = toThreadTurnState(nextThread); const previousShell = state.threadShellById[nextThread.id]; @@ -613,7 +640,7 @@ function writeThreadState(state: AppState, nextThread: Thread, previousThread?: return nextState; } -function removeThreadState(state: AppState, threadId: ThreadId): AppState { +function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { const shell = state.threadShellById[threadId]; if (!shell) { return state; @@ -887,10 +914,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string { } function updateThreadState( - state: AppState, + state: EnvironmentState, threadId: ThreadId, updater: (thread: Thread) => Thread, -): AppState { +): EnvironmentState { const currentThread = getThread(state, threadId); if (!currentThread) { return state; @@ -904,7 +931,7 @@ function updateThreadState( function buildProjectState( projects: ReadonlyArray, -): Pick { +): Pick { return { projectIds: projects.map((project) => project.id), projectById: Object.fromEntries( @@ -916,7 +943,7 @@ function buildProjectState( function buildThreadState( threads: ReadonlyArray, ): Pick< - AppState, + EnvironmentState, | "threadIds" | "threadIdsByProjectId" | "threadShellById" @@ -947,7 +974,6 @@ function buildThreadState( const turnDiffSummaryByThreadId: Record> = {}; const sidebarThreadSummaryById: Record = {}; -<<<<<<< HEAD for (const thread of threads) { threadIds.push(thread.id); threadIdsByProjectId[thread.projectId] = [ @@ -988,20 +1014,66 @@ function buildThreadState( turnDiffSummaryByThreadId, sidebarThreadSummaryById, }; -======= -function resolveTargetEnvironmentId( +} + +function getStoredEnvironmentState( + state: AppState, + environmentId: EnvironmentId, +): EnvironmentState { + return state.environmentStateById[environmentId] ?? initialEnvironmentState; +} + +function projectActiveEnvironmentState(input: { + activeEnvironmentId: EnvironmentId | null; + environmentStateById: Record; +}): AppState { + const projectedState = + input.activeEnvironmentId === null + ? initialEnvironmentState + : (input.environmentStateById[input.activeEnvironmentId] ?? initialEnvironmentState); + + return { + activeEnvironmentId: input.activeEnvironmentId, + environmentStateById: input.environmentStateById, + ...projectedState, + }; +} + +function commitEnvironmentState( state: AppState, - environmentId?: EnvironmentId | null, -): EnvironmentId | null { - return environmentId !== undefined ? environmentId : (state.activeEnvironmentId ?? null); ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) + environmentId: EnvironmentId, + nextEnvironmentState: EnvironmentState, +): AppState { + const currentEnvironmentState = state.environmentStateById[environmentId]; + const environmentStateById = + currentEnvironmentState === nextEnvironmentState + ? state.environmentStateById + : { + ...state.environmentStateById, + [environmentId]: nextEnvironmentState, + }; + + if (environmentStateById === state.environmentStateById) { + return state; + } + + return projectActiveEnvironmentState({ + activeEnvironmentId: state.activeEnvironmentId, + environmentStateById, + }); } -export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { +function syncEnvironmentReadModel( + state: EnvironmentState, + readModel: OrchestrationReadModel, + environmentId: EnvironmentId, +): EnvironmentState { const projects = readModel.projects .filter((project) => project.deletedAt === null) - .map(mapProject); - const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); + .map((project) => mapProject(project, environmentId)); + const threads = readModel.threads + .filter((thread) => thread.deletedAt === null) + .map((thread) => mapThread(thread, environmentId)); return { ...state, ...buildProjectState(projects), @@ -1010,7 +1082,23 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea }; } -export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { +export function syncServerReadModel( + state: AppState, + readModel: OrchestrationReadModel, + environmentId: EnvironmentId, +): AppState { + return commitEnvironmentState( + state, + environmentId, + syncEnvironmentReadModel(getStoredEnvironmentState(state, environmentId), readModel, environmentId), + ); +} + +function applyEnvironmentOrchestrationEvent( + state: EnvironmentState, + event: OrchestrationEvent, + environmentId: EnvironmentId, +): EnvironmentState { switch (event.type) { case "project.created": { const nextProject = mapProject({ @@ -1022,7 +1110,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, deletedAt: null, - }); + }, environmentId); const existingProjectId = state.projectIds.find( (projectId) => @@ -1122,7 +1210,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve activities: [], checkpoints: [], session: null, - }); + }, environmentId); return writeThreadState(state, nextThread, previousThread); } @@ -1489,11 +1577,17 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve export function applyOrchestrationEvents( state: AppState, events: ReadonlyArray, + environmentId: EnvironmentId, ): AppState { if (events.length === 0) { return state; } - return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); + const currentEnvironmentState = getStoredEnvironmentState(state, environmentId); + const nextEnvironmentState = events.reduce( + (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId), + currentEnvironmentState, + ); + return commitEnvironmentState(state, environmentId, nextEnvironmentState); } export const selectProjects = (state: AppState): Project[] => getProjects(state); @@ -1509,65 +1603,55 @@ export const selectThreadById = export const selectSidebarThreadSummaryById = (threadId: ThreadId | null | undefined) => (state: AppState): SidebarThreadSummary | undefined => -<<<<<<< HEAD threadId ? state.sidebarThreadSummaryById[threadId] : undefined; export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => (state: AppState): ThreadId[] => projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; -======= - threadId - ? state.sidebarThreadsByScopedId[ - getThreadScopedId({ - environmentId: state.activeEnvironmentId, - id: threadId, - }) - ] - : undefined; - -export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => { - let cachedProjectScopedId: string | null = null; - let cachedScopedIds: string[] | undefined; - let cachedSidebarThreadsByScopedId: Record | undefined; - let cachedResult: ThreadId[] = EMPTY_THREAD_IDS; - - return (state: AppState): ThreadId[] => { - if (!projectId) { - return EMPTY_THREAD_IDS; - } - const projectScopedId = getProjectScopedId({ - environmentId: state.activeEnvironmentId, - id: projectId, - }); - const scopedIds = state.threadScopedIdsByProjectScopedId[projectScopedId] ?? EMPTY_SCOPED_IDS; - const sidebarThreadsByScopedId = state.sidebarThreadsByScopedId; +export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { + if (state.activeEnvironmentId === null) { + return state; + } - if ( - cachedProjectScopedId === projectScopedId && - cachedScopedIds === scopedIds && - cachedSidebarThreadsByScopedId === sidebarThreadsByScopedId - ) { - return cachedResult; - } + const nextEnvironmentState = updateThreadState( + getStoredEnvironmentState(state, state.activeEnvironmentId), + threadId, + (thread) => { + if (thread.error === error) return thread; + return { ...thread, error }; + }, + ); + return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); +} - const result = scopedIds - .map((scopedId) => sidebarThreadsByScopedId[scopedId]?.id ?? null) - .filter((threadId): threadId is ThreadId => threadId !== null); +export function applyOrchestrationEvent( + state: AppState, + event: OrchestrationEvent, + environmentId: EnvironmentId, +): AppState { + return commitEnvironmentState( + state, + environmentId, + applyEnvironmentOrchestrationEvent( + getStoredEnvironmentState(state, environmentId), + event, + environmentId, + ), + ); +} - cachedProjectScopedId = projectScopedId; - cachedScopedIds = scopedIds; - cachedSidebarThreadsByScopedId = sidebarThreadsByScopedId; - cachedResult = result; - return result; - }; -}; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) +export function setActiveEnvironmentId( + state: AppState, + environmentId: EnvironmentId, +): AppState { + if (state.activeEnvironmentId === environmentId) { + return state; + } -export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - return updateThreadState(state, threadId, (thread) => { - if (thread.error === error) return thread; - return { ...thread, error }; + return projectActiveEnvironmentState({ + activeEnvironmentId: environmentId, + environmentStateById: state.environmentStateById, }); } @@ -1577,31 +1661,49 @@ export function setThreadBranch( branch: string | null, worktreePath: string | null, ): AppState { - return updateThreadState(state, threadId, (thread) => { - if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; - const cwdChanged = thread.worktreePath !== worktreePath; - return { - ...thread, - branch, - worktreePath, - ...(cwdChanged ? { session: null } : {}), - }; - }); + if (state.activeEnvironmentId === null) { + return state; + } + + const nextEnvironmentState = updateThreadState( + getStoredEnvironmentState(state, state.activeEnvironmentId), + threadId, + (thread) => { + if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; + const cwdChanged = thread.worktreePath !== worktreePath; + return { + ...thread, + branch, + worktreePath, + ...(cwdChanged ? { session: null } : {}), + }; + }, + ); + return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); } interface AppStore extends AppState { - syncServerReadModel: (readModel: OrchestrationReadModel) => void; - applyOrchestrationEvent: (event: OrchestrationEvent) => void; - applyOrchestrationEvents: (events: ReadonlyArray) => void; + setActiveEnvironmentId: (environmentId: EnvironmentId) => void; + syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void; + applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; + applyOrchestrationEvents: ( + events: ReadonlyArray, + environmentId: EnvironmentId, + ) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; } export const useStore = create((set) => ({ ...initialState, - syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), - applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), - applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), + setActiveEnvironmentId: (environmentId) => + set((state) => setActiveEnvironmentId(state, environmentId)), + syncServerReadModel: (readModel, environmentId) => + set((state) => syncServerReadModel(state, readModel, environmentId)), + applyOrchestrationEvent: (event, environmentId) => + set((state) => applyOrchestrationEvent(state, event, environmentId)), + applyOrchestrationEvents: (events, environmentId) => + set((state) => applyOrchestrationEvents(state, events, environmentId)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index a54b195428..a544975731 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -82,7 +82,7 @@ export interface TurnDiffSummary { export interface Project { id: ProjectId; - environmentId?: EnvironmentId | null; + environmentId: EnvironmentId; name: string; cwd: string; repositoryIdentity?: RepositoryIdentity | null; @@ -94,7 +94,7 @@ export interface Project { export interface Thread { id: ThreadId; - environmentId?: EnvironmentId | null; + environmentId: EnvironmentId; codexThreadId: string | null; projectId: ProjectId; title: string; @@ -118,6 +118,7 @@ export interface Thread { export interface ThreadShell { id: ThreadId; + environmentId: EnvironmentId; codexThreadId: string | null; projectId: ProjectId; title: string; @@ -139,7 +140,7 @@ export interface ThreadTurnState { export interface SidebarThreadSummary { id: ThreadId; - environmentId?: EnvironmentId | null; + environmentId: EnvironmentId; projectId: ProjectId; title: string; interactionMode: ProviderInteractionMode; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 723661ccbb..1af904495c 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -1,12 +1,15 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", From 97ba9f98ceae01d0ee53faeef0d0309a8917db7e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 11:46:58 -0700 Subject: [PATCH 06/14] Preserve remote scoped state across snapshot syncs - keep untouched environment sidebar entries and project thread indexes stable during local snapshot sync - avoid eagerly resolving bootstrap URLs when an explicit server URL is provided - tighten scoped helpers by removing unused environment/session utilities --- apps/web/src/lib/utils.test.ts | 16 +- apps/web/src/lib/utils.ts | 2 +- apps/web/src/store.test.ts | 178 ++++++++++++++++++ apps/web/src/store.ts | 138 ++++++++++++++ apps/web/src/storeSelectors.ts | 10 + .../client-runtime/src/knownEnvironment.ts | 13 +- packages/client-runtime/src/scoped.ts | 41 +--- 7 files changed, 345 insertions(+), 53 deletions(-) diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 92cb092392..3f4b0ab0d2 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -1,7 +1,11 @@ import { assert, describe, expect, it, vi } from "vitest"; +const { resolvePrimaryEnvironmentBootstrapUrlMock } = vi.hoisted(() => ({ + resolvePrimaryEnvironmentBootstrapUrlMock: vi.fn(() => "http://bootstrap.test:4321"), +})); + vi.mock("../environmentBootstrap", () => ({ - resolvePrimaryEnvironmentBootstrapUrl: vi.fn(() => "http://bootstrap.test:4321"), + resolvePrimaryEnvironmentBootstrapUrl: resolvePrimaryEnvironmentBootstrapUrlMock, })); import { isWindowsPlatform } from "./utils"; @@ -34,4 +38,14 @@ describe("resolveServerUrl", () => { }), ).toBe("wss://override.test:9999/rpc?hello=world"); }); + + it("does not evaluate the bootstrap resolver when an explicit URL is provided", () => { + resolvePrimaryEnvironmentBootstrapUrlMock.mockImplementationOnce(() => { + throw new Error("bootstrap unavailable"); + }); + + expect(resolveServerUrl({ url: "https://override.test:9999" })).toBe( + "https://override.test:9999/", + ); + }); }); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 7c3b5e4590..a74be99d78 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -53,7 +53,7 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString(options?.url, resolvePrimaryEnvironmentBootstrapUrl()); + const rawUrl = firstNonEmptyString(options?.url ?? resolvePrimaryEnvironmentBootstrapUrl()); const parsedUrl = new URL(rawUrl); if (options?.protocol) { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 2fef45fed6..943d3f17b3 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -440,7 +440,185 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); +<<<<<<< HEAD expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); +======= + expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]); + }); + + it("replaces only the targeted environment during snapshot sync", () => { + const localThread = makeThread(); + const remoteThread = makeThread({ + id: ThreadId.makeUnsafe("thread-remote"), + projectId: ProjectId.makeUnsafe("project-remote"), + environmentId: remoteEnvironmentId, + title: "Remote thread", + }); + const initialState: AppState = { + ...makeState(localThread), + projects: [ + ...makeState(localThread).projects, + { + id: ProjectId.makeUnsafe("project-remote"), + environmentId: remoteEnvironmentId, + name: "Remote project", + cwd: "/tmp/remote-project", + repositoryIdentity: null, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + scripts: [], + }, + ], + threads: [localThread, remoteThread], + }; + + const next = syncServerReadModel( + initialState, + makeReadModel( + makeReadModelThread({ + title: "Updated local thread", + }), + ), + localEnvironmentId, + ); + + expect(next.projects).toHaveLength(2); + expect(next.threads).toHaveLength(2); + expect(next.threads.find((thread) => thread.environmentId === remoteEnvironmentId)?.title).toBe( + "Remote thread", + ); + expect(next.threads.find((thread) => thread.environmentId === localEnvironmentId)?.title).toBe( + "Updated local thread", + ); + }); + + it("preserves sidebar index references for untouched environments during snapshot sync", () => { + const localThread = makeThread(); + const remoteProjectId = ProjectId.makeUnsafe("project-remote"); + const remoteThread = makeThread({ + id: ThreadId.makeUnsafe("thread-remote"), + projectId: remoteProjectId, + environmentId: remoteEnvironmentId, + title: "Remote thread", + }); + const remoteThreadScopedId = getThreadScopedId({ + environmentId: remoteEnvironmentId, + id: remoteThread.id, + }); + const remoteProjectScopedId = getProjectScopedId({ + environmentId: remoteEnvironmentId, + id: remoteProjectId, + }); + const remoteSidebarSummary = { + id: remoteThread.id, + environmentId: remoteEnvironmentId, + projectId: remoteProjectId, + title: remoteThread.title, + interactionMode: remoteThread.interactionMode, + session: remoteThread.session, + createdAt: remoteThread.createdAt, + archivedAt: remoteThread.archivedAt, + updatedAt: remoteThread.updatedAt, + latestTurn: remoteThread.latestTurn, + branch: remoteThread.branch, + worktreePath: remoteThread.worktreePath, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } as const; + const remoteProjectThreadIds = [remoteThreadScopedId]; + const initialState: AppState = { + ...makeState(localThread), + projects: [ + ...makeState(localThread).projects, + { + id: remoteProjectId, + environmentId: remoteEnvironmentId, + name: "Remote project", + cwd: "/tmp/remote-project", + repositoryIdentity: null, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + scripts: [], + }, + ], + threads: [localThread, remoteThread], + sidebarThreadsByScopedId: { + [getThreadScopedId({ + environmentId: localEnvironmentId, + id: localThread.id, + })]: { + id: localThread.id, + environmentId: localEnvironmentId, + projectId: localThread.projectId, + title: localThread.title, + interactionMode: localThread.interactionMode, + session: localThread.session, + createdAt: localThread.createdAt, + archivedAt: localThread.archivedAt, + updatedAt: localThread.updatedAt, + latestTurn: localThread.latestTurn, + branch: localThread.branch, + worktreePath: localThread.worktreePath, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }, + [remoteThreadScopedId]: remoteSidebarSummary, + }, + threadScopedIdsByProjectScopedId: { + [getProjectScopedId({ + environmentId: localEnvironmentId, + id: localThread.projectId, + })]: [ + getThreadScopedId({ + environmentId: localEnvironmentId, + id: localThread.id, + }), + ], + [remoteProjectScopedId]: remoteProjectThreadIds, + }, + }; + + const next = syncServerReadModel( + initialState, + makeReadModel( + makeReadModelThread({ + title: "Updated local thread", + }), + ), + localEnvironmentId, + ); + + expect(next.sidebarThreadsByScopedId[remoteThreadScopedId]).toBe(remoteSidebarSummary); + expect(next.threadScopedIdsByProjectScopedId[remoteProjectScopedId]).toBe( + remoteProjectThreadIds, + ); + }); + + it("returns a stable thread id array for unchanged project thread inputs", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + const syncedState = syncServerReadModel( + makeState(makeThread()), + makeReadModel(makeReadModelThread({ projectId })), + localEnvironmentId, + ); + const selectThreadIds = selectThreadIdsByProjectId(projectId); + + const first = selectThreadIds(syncedState); + const second = selectThreadIds({ + ...syncedState, + bootstrapComplete: false, + }); + + expect(first).toBe(second); +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 54429f9c15..c7d1e72a8e 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -385,6 +385,7 @@ function buildProposedPlanSlice(thread: Thread): { }; } +<<<<<<< HEAD function buildTurnDiffSlice(thread: Thread): { ids: TurnId[]; byId: Record; @@ -693,6 +694,60 @@ function removeThreadState(state: EnvironmentState, threadId: ThreadId): Environ turnDiffSummaryByThreadId, sidebarThreadSummaryById, }; +======= +function isScopedRecordKeyForEnvironment( + scopedRecordKey: string, + environmentId: EnvironmentId, + kind: "project" | "thread", +): boolean { + return scopedRecordKey.startsWith(`${environmentId}:${kind}:`); +} + +function mergeSidebarThreadsByScopedId( + current: AppState["sidebarThreadsByScopedId"], + environmentId: EnvironmentId, + threadsForEnvironment: ReadonlyArray, +): AppState["sidebarThreadsByScopedId"] { + const next = Object.fromEntries( + Object.entries(current).filter( + ([scopedId]) => !isScopedRecordKeyForEnvironment(scopedId, environmentId, "thread"), + ), + ); + + for (const thread of threadsForEnvironment) { + const scopedId = getThreadScopedId({ + environmentId: thread.environmentId, + id: thread.id, + }); + const nextSummary = buildSidebarThreadSummary(thread); + const previousSummary = current[scopedId]; + next[scopedId] = + sidebarThreadSummariesEqual(previousSummary, nextSummary) && previousSummary !== undefined + ? previousSummary + : nextSummary; + } + + return next; +} + +function mergeThreadScopedIdsByProjectScopedId( + current: AppState["threadScopedIdsByProjectScopedId"], + environmentId: EnvironmentId, + threadsForEnvironment: ReadonlyArray, +): AppState["threadScopedIdsByProjectScopedId"] { + const next = Object.fromEntries( + Object.entries(current).filter( + ([scopedId]) => !isScopedRecordKeyForEnvironment(scopedId, environmentId, "project"), + ), + ); + const nextEntries = buildThreadScopedIdsByProjectScopedId(threadsForEnvironment); + + for (const [scopedId, threadScopedIds] of Object.entries(nextEntries)) { + next[scopedId] = threadScopedIds; + } + + return next; +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) } function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { @@ -1087,11 +1142,46 @@ export function syncServerReadModel( readModel: OrchestrationReadModel, environmentId: EnvironmentId, ): AppState { +<<<<<<< HEAD return commitEnvironmentState( state, environmentId, syncEnvironmentReadModel(getStoredEnvironmentState(state, environmentId), readModel, environmentId), ); +======= + const projectsForEnvironment = readModel.projects + .filter((project) => project.deletedAt === null) + .map((project) => mapProject(project, environmentId)); + const threadsForEnvironment = readModel.threads + .filter((thread) => thread.deletedAt === null) + .map((thread) => mapThread(thread, environmentId)); + const projects = [ + ...state.projects.filter((project) => !sameEnvironmentId(project.environmentId, environmentId)), + ...projectsForEnvironment, + ]; + const threads = [ + ...state.threads.filter((thread) => !sameEnvironmentId(thread.environmentId, environmentId)), + ...threadsForEnvironment, + ]; + const sidebarThreadsByScopedId = mergeSidebarThreadsByScopedId( + state.sidebarThreadsByScopedId, + environmentId, + threadsForEnvironment, + ); + const threadScopedIdsByProjectScopedId = mergeThreadScopedIdsByProjectScopedId( + state.threadScopedIdsByProjectScopedId, + environmentId, + threadsForEnvironment, + ); + return { + ...state, + projects, + threads, + sidebarThreadsByScopedId, + threadScopedIdsByProjectScopedId, + bootstrapComplete: true, + }; +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) } function applyEnvironmentOrchestrationEvent( @@ -1599,6 +1689,7 @@ export const selectProjectById = export const selectThreadById = (threadId: ThreadId | null | undefined) => (state: AppState): Thread | undefined => +<<<<<<< HEAD threadId ? getThread(state, threadId) : undefined; export const selectSidebarThreadSummaryById = (threadId: ThreadId | null | undefined) => @@ -1608,6 +1699,53 @@ export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => (state: AppState): ThreadId[] => projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; +======= + threadId + ? state.threads.find( + (thread) => + thread.id === threadId && + sameEnvironmentId(thread.environmentId, state.activeEnvironmentId), + ) + : undefined; + +export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => { + let cachedProjectScopedId: string | null = null; + let cachedScopedIds: string[] | undefined; + let cachedSidebarThreadsByScopedId: Record | undefined; + let cachedResult: ThreadId[] = EMPTY_THREAD_IDS; + + return (state: AppState): ThreadId[] => { + if (!projectId) { + return EMPTY_THREAD_IDS; + } + + const projectScopedId = getProjectScopedId({ + environmentId: state.activeEnvironmentId, + id: projectId, + }); + const scopedIds = state.threadScopedIdsByProjectScopedId[projectScopedId] ?? EMPTY_SCOPED_IDS; + const sidebarThreadsByScopedId = state.sidebarThreadsByScopedId; + + if ( + cachedProjectScopedId === projectScopedId && + cachedScopedIds === scopedIds && + cachedSidebarThreadsByScopedId === sidebarThreadsByScopedId + ) { + return cachedResult; + } + + const result = scopedIds + .map((scopedId) => sidebarThreadsByScopedId[scopedId]?.id ?? null) + .filter((threadId): threadId is ThreadId => threadId !== null); + + cachedProjectScopedId = projectScopedId; + cachedScopedIds = scopedIds; + cachedSidebarThreadsByScopedId = sidebarThreadsByScopedId; + cachedResult = result; + return result; + }; +}; +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { if (state.activeEnvironmentId === null) { diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index a7a7440eb2..be70a86b13 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,3 +1,4 @@ +<<<<<<< HEAD import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; import { type AppState } from "./store"; import { @@ -10,6 +11,12 @@ import { type ThreadTurnState, type TurnDiffSummary, } from "./types"; +======= +import { type ThreadId } from "@t3tools/contracts"; +import { useMemo } from "react"; +import { selectProjectById, selectThreadById, useStore } from "./store"; +import { type Project, type Thread } from "./types"; +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) const EMPTY_MESSAGES: ChatMessage[] = []; const EMPTY_ACTIVITIES: Thread["activities"] = []; @@ -35,6 +42,7 @@ export function createProjectSelector( ): (state: AppState) => Project | undefined { return (state) => (projectId ? state.projectById[projectId] : undefined); } +<<<<<<< HEAD export function createSidebarThreadSummarySelector( threadId: ThreadId | null | undefined, @@ -144,3 +152,5 @@ export function createThreadSelector( return previousThread; }; } +======= +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/knownEnvironment.ts index 40c9054f17..3a5e0d0e7d 100644 --- a/packages/client-runtime/src/knownEnvironment.ts +++ b/packages/client-runtime/src/knownEnvironment.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; export interface KnownEnvironmentConnectionTarget { readonly type: "ws"; @@ -37,14 +37,3 @@ export function getKnownEnvironmentBaseUrl( ): string | null { return environment?.target.wsUrl ?? null; } - -export function attachEnvironmentDescriptor( - environment: KnownEnvironment, - descriptor: ExecutionEnvironmentDescriptor, -): KnownEnvironment { - return { - ...environment, - environmentId: descriptor.environmentId, - label: descriptor.label, - }; -} diff --git a/packages/client-runtime/src/scoped.ts b/packages/client-runtime/src/scoped.ts index 54ffd6dcbb..7fc721cb3a 100644 --- a/packages/client-runtime/src/scoped.ts +++ b/packages/client-runtime/src/scoped.ts @@ -2,20 +2,10 @@ import type { EnvironmentId, ProjectId, ScopedProjectRef, - ScopedThreadSessionRef, ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; -interface EnvironmentScopedRef { - readonly environmentId: EnvironmentId; - readonly id: TId; -} - -export interface EnvironmentClientRegistry { - readonly getClient: (environmentId: EnvironmentId) => TClient | null | undefined; -} - export function scopeProjectRef( environmentId: EnvironmentId, projectId: ProjectId, @@ -27,34 +17,7 @@ export function scopeThreadRef(environmentId: EnvironmentId, threadId: ThreadId) return { environmentId, threadId }; } -export function scopeThreadSessionRef( - environmentId: EnvironmentId, - threadId: ThreadId, -): ScopedThreadSessionRef { - return { environmentId, threadId }; -} - -export function scopedRefKey( - ref: EnvironmentScopedRef | ScopedProjectRef | ScopedThreadRef | ScopedThreadSessionRef, -): string { - const localId = "id" in ref ? ref.id : "projectId" in ref ? ref.projectId : ref.threadId; +export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { + const localId = "projectId" in ref ? ref.projectId : ref.threadId; return `${ref.environmentId}:${localId}`; } - -export function resolveEnvironmentClient( - registry: EnvironmentClientRegistry, - ref: EnvironmentScopedRef, -): TClient { - const client = registry.getClient(ref.environmentId); - if (!client) { - throw new Error(`No client registered for environment ${ref.environmentId}.`); - } - return client; -} - -export function tagEnvironmentValue( - environmentId: EnvironmentId, - value: T, -): { readonly environmentId: EnvironmentId; readonly value: T } { - return { environmentId, value }; -} From 74250e92684c1952135667524afd69078f55be75 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 11:55:07 -0700 Subject: [PATCH 07/14] non empty --- apps/web/src/lib/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index a74be99d78..7c3b5e4590 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -53,7 +53,7 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString(options?.url ?? resolvePrimaryEnvironmentBootstrapUrl()); + const rawUrl = firstNonEmptyString(options?.url, resolvePrimaryEnvironmentBootstrapUrl()); const parsedUrl = new URL(rawUrl); if (options?.protocol) { From 240438d23877447f1e8943c7912e12b7ee6e113b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 11:58:27 -0700 Subject: [PATCH 08/14] fine --- apps/web/src/lib/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 7c3b5e4590..b94963b9c4 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -53,7 +53,7 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString(options?.url, resolvePrimaryEnvironmentBootstrapUrl()); + const rawUrl = firstNonEmptyString(options?.url || resolvePrimaryEnvironmentBootstrapUrl()); const parsedUrl = new URL(rawUrl); if (options?.protocol) { From 938f3d68f3f2b7963d849344a5801fc2a52f2d5a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 12:09:03 -0700 Subject: [PATCH 09/14] Cache repository identity lookups by TTL - add TTL-backed positive and negative caching for repository identity resolution - refresh identities when remotes appear or change after cache expiry - cover late-remote and remote-change cases in tests --- .../Layers/RepositoryIdentityResolver.test.ts | 100 +++++++++++++++--- 1 file changed, 87 insertions(+), 13 deletions(-) diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 2992f17178..9f247b2a07 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -1,14 +1,30 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Effect, FileSystem } from "effect"; +import { Duration, Effect, FileSystem, Layer } from "effect"; +import { TestClock } from "effect/testing"; import { runProcess } from "../../processRunner.ts"; import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; -import { RepositoryIdentityResolverLive } from "./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* () { @@ -95,25 +111,83 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); - it.effect("re-resolves after a remote is configured later in the same process", () => + 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-late-remote-test-", + 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).toBeNull(); - - yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - - const resolvedIdentity = yield* resolver.resolve(cwd); - expect(resolvedIdentity).not.toBeNull(); - expect(resolvedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(resolvedIdentity?.name).toBe("t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + 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), + }), + ), + ), + ), ); }); From fc2de818221d047e4b5e07931d8f9b19b273f4b6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 12:36:28 -0700 Subject: [PATCH 10/14] move --- apps/server/src/config.ts | 2 ++ apps/server/src/environment/Layers/ServerEnvironment.ts | 9 +++------ 2 files changed, 5 insertions(+), 6 deletions(-) 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.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index 2978af7dcb..1e088724c7 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -6,8 +6,6 @@ import { ServerConfig } from "../../config.ts"; import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; import { version } from "../../../package.json" with { type: "json" }; -const ENVIRONMENT_ID_FILENAME = "environment-id"; - function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { switch (process.platform) { case "darwin": @@ -36,17 +34,16 @@ export const makeServerEnvironment = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; - const environmentIdPath = path.join(serverConfig.stateDir, ENVIRONMENT_ID_FILENAME); const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem - .exists(environmentIdPath) + .exists(serverConfig.environmentIdPath) .pipe(Effect.orElseSucceed(() => false)); if (!exists) { return null; } - const raw = yield* fileSystem.readFileString(environmentIdPath).pipe( + const raw = yield* fileSystem.readFileString(serverConfig.environmentIdPath).pipe( Effect.orElseSucceed(() => ""), Effect.map((value) => value.trim()), ); @@ -55,7 +52,7 @@ export const makeServerEnvironment = Effect.gen(function* () { }); const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(environmentIdPath, `${value}\n`); + fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); const environmentIdRaw = yield* readPersistedEnvironmentId.pipe( Effect.flatMap((persisted) => { From 403ac968e964ea1cfb90a97fe98d3d29f7a53652 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 12:47:14 -0700 Subject: [PATCH 11/14] kewl --- .../environment/Layers/ServerEnvironment.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index 1e088724c7..75812239d2 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -1,6 +1,5 @@ -import { randomUUID } from "node:crypto"; import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, FileSystem, Layer, Path, Random } from "effect"; import { ServerConfig } from "../../config.ts"; import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; @@ -30,7 +29,7 @@ function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { } } -export const makeServerEnvironment = Effect.gen(function* () { +export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; @@ -54,16 +53,16 @@ export const makeServerEnvironment = Effect.gen(function* () { const persistEnvironmentId = (value: string) => fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); - const environmentIdRaw = yield* readPersistedEnvironmentId.pipe( - Effect.flatMap((persisted) => { - if (persisted) { - return Effect.succeed(persisted); - } + const environmentIdRaw = yield* Effect.gen(function* () { + const persisted = yield* readPersistedEnvironmentId; + if (persisted) { + return persisted; + } - const generated = randomUUID(); - return persistEnvironmentId(generated).pipe(Effect.as(generated)); - }), - ); + const generated = yield* Random.nextUUIDv4; + yield* persistEnvironmentId(generated); + return generated; + }); const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw); const cwdBaseName = path.basename(serverConfig.cwd).trim(); @@ -93,4 +92,4 @@ export const makeServerEnvironment = Effect.gen(function* () { } satisfies ServerEnvironmentShape; }); -export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment); +export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()); From 70d373163b491ae9ddfa01d3de22a685911aeede Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 12:51:11 -0700 Subject: [PATCH 12/14] Handle ports in remote URL normalization - Strip explicit ports from URL-style git remotes - Add regression coverage for HTTPS and SSH remotes --- packages/shared/src/git.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 55871f8ed5..a39c924447 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -89,13 +89,25 @@ export function normalizeGitRemoteUrl(value: string): string { .replace(/\/+$/g, "") .replace(/\.git$/i, "") .toLowerCase(); - const hostAndPath = - /^(?:git@|ssh:\/\/git@|https:\/\/|http:\/\/|git:\/\/)([^/:]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec( - normalized, - ); - if (hostAndPath?.[1] && hostAndPath[2]) { - return `${hostAndPath[1]}/${hostAndPath[2]}`; + if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { + try { + const url = new URL(normalized); + const repositoryPath = url.pathname + .split("/") + .filter((segment) => segment.length > 0) + .join("/"); + if (url.hostname && repositoryPath.includes("/")) { + return `${url.hostname}/${repositoryPath}`; + } + } catch { + return normalized; + } + } + + const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); + if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { + return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; } return normalized; From babe73c90c25ae2382585b86c633873b8fcee0c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 12:58:07 -0700 Subject: [PATCH 13/14] Handle empty server URLs and stale sidebar threads - Preserve persisted environment IDs on read failures - Scope sidebar thread lookups to the active environment - Treat empty server URLs as unset --- .../Layers/ServerEnvironment.test.ts | 109 +++++++++++++++++- .../environment/Layers/ServerEnvironment.ts | 7 +- apps/web/src/lib/utils.test.ts | 4 + apps/web/src/lib/utils.ts | 12 +- 4 files changed, 117 insertions(+), 15 deletions(-) diff --git a/apps/server/src/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/Layers/ServerEnvironment.test.ts index 35b6803fc9..4042eaac71 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.test.ts @@ -1,14 +1,58 @@ +import * as nodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer } from "effect"; +import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; -import { ServerConfig } from "../../config.ts"; +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* () { @@ -30,4 +74,65 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { 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 index 75812239d2..fd58425dee 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -42,10 +42,9 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function return null; } - const raw = yield* fileSystem.readFileString(serverConfig.environmentIdPath).pipe( - Effect.orElseSucceed(() => ""), - Effect.map((value) => value.trim()), - ); + const raw = yield* fileSystem + .readFileString(serverConfig.environmentIdPath) + .pipe(Effect.map((value) => value.trim())); return raw.length > 0 ? raw : null; }); diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 3f4b0ab0d2..317933d677 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -24,6 +24,10 @@ describe("isWindowsPlatform", () => { }); describe("resolveServerUrl", () => { + it("falls back to the bootstrap environment URL when the explicit URL is empty", () => { + expect(resolveServerUrl({ url: "" })).toBe("http://bootstrap.test:4321/"); + }); + it("uses the bootstrap environment URL when no explicit URL is provided", () => { expect(resolveServerUrl()).toBe("http://bootstrap.test:4321/"); }); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index b94963b9c4..2f826dda02 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -38,14 +38,6 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty); -const firstNonEmptyString = (...values: unknown[]): string => { - for (const value of values) { - if (isNonEmptyString(value)) { - return value; - } - } - throw new Error("No non-empty string provided"); -}; export const resolveServerUrl = (options?: { url?: string | undefined; @@ -53,7 +45,9 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString(options?.url || resolvePrimaryEnvironmentBootstrapUrl()); + const rawUrl = isNonEmptyString(options?.url) + ? options.url + : resolvePrimaryEnvironmentBootstrapUrl(); const parsedUrl = new URL(rawUrl); if (options?.protocol) { From 54f905c86597a8cf74ae70cb1559ed83de393678 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 13:51:37 -0700 Subject: [PATCH 14/14] Adapt multi-environment store to atomic refactor base Co-authored-by: codex --- .../Layers/RepositoryIdentityResolver.test.ts | 6 +- .../web/src/components/ChatView.logic.test.ts | 4 + apps/web/src/routes/__root.tsx | 6 +- apps/web/src/store.test.ts | 274 ++++-------------- apps/web/src/store.ts | 263 ++++++----------- apps/web/src/storeSelectors.ts | 10 - 6 files changed, 158 insertions(+), 405 deletions(-) diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 9f247b2a07..57f4464804 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -41,9 +41,9 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(identity?.displayName).toBe("T3Tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); expect(identity?.provider).toBe("github"); - expect(identity?.owner).toBe("T3Tools"); + expect(identity?.owner).toBe("t3tools"); expect(identity?.name).toBe("t3code"); }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); @@ -86,7 +86,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity).not.toBeNull(); expect(identity?.locator.remoteName).toBe("upstream"); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(identity?.displayName).toBe("T3Tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 03bc9ed9f5..184a1fb11c 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -212,10 +212,12 @@ const makeThread = (input?: { function setStoreThreads(threads: ReadonlyArray>) { const projectId = ProjectId.makeUnsafe("project-1"); useStore.setState({ + activeEnvironmentId: localEnvironmentId, projectIds: [projectId], projectById: { [projectId]: { id: projectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -236,6 +238,7 @@ function setStoreThreads(threads: ReadonlyArray>) thread.id, { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -304,6 +307,7 @@ function setStoreThreads(threads: ReadonlyArray>) ), sidebarThreadSummaryById: {}, bootstrapComplete: true, + environmentStateById: {}, }); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4cee742092..ebafc8f029 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -611,11 +611,7 @@ function EventRouter() { return; } const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); - if ( - thread && - thread.environmentId === currentEnvironmentId && - thread.archivedAt !== null - ) { + if (thread && thread.environmentId === currentEnvironmentId && thread.archivedAt !== null) { return; } applyTerminalEvent(event); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 943d3f17b3..4ba0aa9614 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -307,7 +307,11 @@ describe("store read model sync", () => { bootstrapComplete: false, }; - const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({}))); + const next = syncServerReadModel( + initialState, + makeReadModel(makeReadModelThread({})), + localEnvironmentId, + ); expect(next.bootstrapComplete).toBe(true); }); @@ -323,7 +327,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); }); @@ -348,7 +352,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); }); @@ -361,7 +365,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); @@ -377,6 +381,7 @@ describe("store read model sync", () => { archivedAt, }), ), + localEnvironmentId, ); expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); @@ -391,6 +396,7 @@ describe("store read model sync", () => { projectById: { [project2]: { id: project2, + environmentId: localEnvironmentId, name: "Project 2", cwd: "/tmp/project-2", defaultModelSelection: { @@ -403,6 +409,7 @@ describe("store read model sync", () => { }, [project1]: { id: project1, + environmentId: localEnvironmentId, name: "Project 1", cwd: "/tmp/project-1", defaultModelSelection: { @@ -438,187 +445,9 @@ describe("store read model sync", () => { threads: [], }; - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); -<<<<<<< HEAD expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); -======= - expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]); - }); - - it("replaces only the targeted environment during snapshot sync", () => { - const localThread = makeThread(); - const remoteThread = makeThread({ - id: ThreadId.makeUnsafe("thread-remote"), - projectId: ProjectId.makeUnsafe("project-remote"), - environmentId: remoteEnvironmentId, - title: "Remote thread", - }); - const initialState: AppState = { - ...makeState(localThread), - projects: [ - ...makeState(localThread).projects, - { - id: ProjectId.makeUnsafe("project-remote"), - environmentId: remoteEnvironmentId, - name: "Remote project", - cwd: "/tmp/remote-project", - repositoryIdentity: null, - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - scripts: [], - }, - ], - threads: [localThread, remoteThread], - }; - - const next = syncServerReadModel( - initialState, - makeReadModel( - makeReadModelThread({ - title: "Updated local thread", - }), - ), - localEnvironmentId, - ); - - expect(next.projects).toHaveLength(2); - expect(next.threads).toHaveLength(2); - expect(next.threads.find((thread) => thread.environmentId === remoteEnvironmentId)?.title).toBe( - "Remote thread", - ); - expect(next.threads.find((thread) => thread.environmentId === localEnvironmentId)?.title).toBe( - "Updated local thread", - ); - }); - - it("preserves sidebar index references for untouched environments during snapshot sync", () => { - const localThread = makeThread(); - const remoteProjectId = ProjectId.makeUnsafe("project-remote"); - const remoteThread = makeThread({ - id: ThreadId.makeUnsafe("thread-remote"), - projectId: remoteProjectId, - environmentId: remoteEnvironmentId, - title: "Remote thread", - }); - const remoteThreadScopedId = getThreadScopedId({ - environmentId: remoteEnvironmentId, - id: remoteThread.id, - }); - const remoteProjectScopedId = getProjectScopedId({ - environmentId: remoteEnvironmentId, - id: remoteProjectId, - }); - const remoteSidebarSummary = { - id: remoteThread.id, - environmentId: remoteEnvironmentId, - projectId: remoteProjectId, - title: remoteThread.title, - interactionMode: remoteThread.interactionMode, - session: remoteThread.session, - createdAt: remoteThread.createdAt, - archivedAt: remoteThread.archivedAt, - updatedAt: remoteThread.updatedAt, - latestTurn: remoteThread.latestTurn, - branch: remoteThread.branch, - worktreePath: remoteThread.worktreePath, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - } as const; - const remoteProjectThreadIds = [remoteThreadScopedId]; - const initialState: AppState = { - ...makeState(localThread), - projects: [ - ...makeState(localThread).projects, - { - id: remoteProjectId, - environmentId: remoteEnvironmentId, - name: "Remote project", - cwd: "/tmp/remote-project", - repositoryIdentity: null, - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - scripts: [], - }, - ], - threads: [localThread, remoteThread], - sidebarThreadsByScopedId: { - [getThreadScopedId({ - environmentId: localEnvironmentId, - id: localThread.id, - })]: { - id: localThread.id, - environmentId: localEnvironmentId, - projectId: localThread.projectId, - title: localThread.title, - interactionMode: localThread.interactionMode, - session: localThread.session, - createdAt: localThread.createdAt, - archivedAt: localThread.archivedAt, - updatedAt: localThread.updatedAt, - latestTurn: localThread.latestTurn, - branch: localThread.branch, - worktreePath: localThread.worktreePath, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }, - [remoteThreadScopedId]: remoteSidebarSummary, - }, - threadScopedIdsByProjectScopedId: { - [getProjectScopedId({ - environmentId: localEnvironmentId, - id: localThread.projectId, - })]: [ - getThreadScopedId({ - environmentId: localEnvironmentId, - id: localThread.id, - }), - ], - [remoteProjectScopedId]: remoteProjectThreadIds, - }, - }; - - const next = syncServerReadModel( - initialState, - makeReadModel( - makeReadModelThread({ - title: "Updated local thread", - }), - ), - localEnvironmentId, - ); - - expect(next.sidebarThreadsByScopedId[remoteThreadScopedId]).toBe(remoteSidebarSummary); - expect(next.threadScopedIdsByProjectScopedId[remoteProjectScopedId]).toBe( - remoteProjectThreadIds, - ); - }); - - it("returns a stable thread id array for unchanged project thread inputs", () => { - const projectId = ProjectId.makeUnsafe("project-1"); - const syncedState = syncServerReadModel( - makeState(makeThread()), - makeReadModel(makeReadModelThread({ projectId })), - localEnvironmentId, - ); - const selectThreadIds = selectThreadIdsByProjectId(projectId); - - const first = selectThreadIds(syncedState); - const second = selectThreadIds({ - ...syncedState, - bootstrapComplete: false, - }); - - expect(first).toBe(second); ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) }); }); @@ -636,6 +465,7 @@ describe("incremental orchestration updates", () => { title: "Updated title", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(next.bootstrapComplete).toBe(false); @@ -651,6 +481,7 @@ describe("incremental orchestration updates", () => { projectId: ProjectId.makeUnsafe("project-missing"), deletedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); const nextAfterThreadDelete = applyOrchestrationEvent( state, @@ -658,6 +489,7 @@ describe("incremental orchestration updates", () => { threadId: ThreadId.makeUnsafe("thread-missing"), deletedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(nextAfterProjectDelete).toBe(state); @@ -672,6 +504,7 @@ describe("incremental orchestration updates", () => { projectById: { [originalProjectId]: { id: originalProjectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -699,6 +532,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(projectsOf(next)).toHaveLength(1); @@ -724,6 +558,7 @@ describe("incremental orchestration updates", () => { projectById: { [originalProjectId]: { id: originalProjectId, + environmentId: localEnvironmentId, name: "Project 1", cwd: "/tmp/project-1", defaultModelSelection: { @@ -736,6 +571,7 @@ describe("incremental orchestration updates", () => { }, [recreatedProjectId]: { id: recreatedProjectId, + environmentId: localEnvironmentId, name: "Project 2", cwd: "/tmp/project-2", defaultModelSelection: { @@ -766,6 +602,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)).toHaveLength(1); @@ -797,6 +634,7 @@ describe("incremental orchestration updates", () => { ...makeState(thread1).threadShellById, [thread2.id]: { id: thread2.id, + environmentId: thread2.environmentId, codexThreadId: thread2.codexThreadId, projectId: thread2.projectId, title: thread2.title, @@ -873,6 +711,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); @@ -896,38 +735,42 @@ describe("incremental orchestration updates", () => { }); const state = makeState(thread); - const next = applyOrchestrationEvents(state, [ - makeEvent( - "thread.session-set", - { - threadId: thread.id, - session: { + const next = applyOrchestrationEvents( + state, + [ + makeEvent( + "thread.session-set", + { threadId: thread.id, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.makeUnsafe("turn-1"), - lastError: null, - updatedAt: "2026-02-27T00:00:02.000Z", + session: { + threadId: thread.id, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.makeUnsafe("turn-1"), + lastError: null, + updatedAt: "2026-02-27T00:00:02.000Z", + }, }, - }, - { sequence: 2 }, - ), - makeEvent( - "thread.message-sent", - { - threadId: thread.id, - messageId: MessageId.makeUnsafe("assistant-1"), - role: "assistant", - text: "done", - turnId: TurnId.makeUnsafe("turn-1"), - streaming: false, - createdAt: "2026-02-27T00:00:03.000Z", - updatedAt: "2026-02-27T00:00:03.000Z", - }, - { sequence: 3 }, - ), - ]); + { sequence: 2 }, + ), + makeEvent( + "thread.message-sent", + { + threadId: thread.id, + messageId: MessageId.makeUnsafe("assistant-1"), + role: "assistant", + text: "done", + turnId: TurnId.makeUnsafe("turn-1"), + streaming: false, + createdAt: "2026-02-27T00:00:03.000Z", + updatedAt: "2026-02-27T00:00:03.000Z", + }, + { sequence: 3 }, + ), + ], + localEnvironmentId, + ); expect(threadsOf(next)[0]?.session?.status).toBe("running"); expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); @@ -960,6 +803,7 @@ describe("incremental orchestration updates", () => { assistantMessageId: MessageId.makeUnsafe("assistant-1"), completedAt: "2026-02-27T00:00:04.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); @@ -1004,6 +848,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:03.000Z", updatedAt: "2026-02-27T00:00:03.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( @@ -1113,6 +958,7 @@ describe("incremental orchestration updates", () => { threadId: ThreadId.makeUnsafe("thread-1"), turnCount: 1, }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ @@ -1171,6 +1017,7 @@ describe("incremental orchestration updates", () => { threadId: thread.id, turnCount: 1, }), + localEnvironmentId, ); expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); @@ -1189,6 +1036,7 @@ describe("incremental orchestration updates", () => { updatedAt: "2026-02-27T00:00:04.000Z", }, }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index c7d1e72a8e..dec4587aba 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -385,7 +385,6 @@ function buildProposedPlanSlice(thread: Thread): { }; } -<<<<<<< HEAD function buildTurnDiffSlice(thread: Thread): { ids: TurnId[]; byId: Record; @@ -694,60 +693,6 @@ function removeThreadState(state: EnvironmentState, threadId: ThreadId): Environ turnDiffSummaryByThreadId, sidebarThreadSummaryById, }; -======= -function isScopedRecordKeyForEnvironment( - scopedRecordKey: string, - environmentId: EnvironmentId, - kind: "project" | "thread", -): boolean { - return scopedRecordKey.startsWith(`${environmentId}:${kind}:`); -} - -function mergeSidebarThreadsByScopedId( - current: AppState["sidebarThreadsByScopedId"], - environmentId: EnvironmentId, - threadsForEnvironment: ReadonlyArray, -): AppState["sidebarThreadsByScopedId"] { - const next = Object.fromEntries( - Object.entries(current).filter( - ([scopedId]) => !isScopedRecordKeyForEnvironment(scopedId, environmentId, "thread"), - ), - ); - - for (const thread of threadsForEnvironment) { - const scopedId = getThreadScopedId({ - environmentId: thread.environmentId, - id: thread.id, - }); - const nextSummary = buildSidebarThreadSummary(thread); - const previousSummary = current[scopedId]; - next[scopedId] = - sidebarThreadSummariesEqual(previousSummary, nextSummary) && previousSummary !== undefined - ? previousSummary - : nextSummary; - } - - return next; -} - -function mergeThreadScopedIdsByProjectScopedId( - current: AppState["threadScopedIdsByProjectScopedId"], - environmentId: EnvironmentId, - threadsForEnvironment: ReadonlyArray, -): AppState["threadScopedIdsByProjectScopedId"] { - const next = Object.fromEntries( - Object.entries(current).filter( - ([scopedId]) => !isScopedRecordKeyForEnvironment(scopedId, environmentId, "project"), - ), - ); - const nextEntries = buildThreadScopedIdsByProjectScopedId(threadsForEnvironment); - - for (const [scopedId, threadScopedIds] of Object.entries(nextEntries)) { - next[scopedId] = threadScopedIds; - } - - return next; ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) } function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { @@ -1075,7 +1020,53 @@ function getStoredEnvironmentState( state: AppState, environmentId: EnvironmentId, ): EnvironmentState { - return state.environmentStateById[environmentId] ?? initialEnvironmentState; + const storedEnvironmentState = state.environmentStateById[environmentId]; + if (state.activeEnvironmentId !== environmentId) { + return storedEnvironmentState ?? initialEnvironmentState; + } + + if ( + storedEnvironmentState && + storedEnvironmentState.projectIds === state.projectIds && + storedEnvironmentState.projectById === state.projectById && + storedEnvironmentState.threadIds === state.threadIds && + storedEnvironmentState.threadIdsByProjectId === state.threadIdsByProjectId && + storedEnvironmentState.threadShellById === state.threadShellById && + storedEnvironmentState.threadSessionById === state.threadSessionById && + storedEnvironmentState.threadTurnStateById === state.threadTurnStateById && + storedEnvironmentState.messageIdsByThreadId === state.messageIdsByThreadId && + storedEnvironmentState.messageByThreadId === state.messageByThreadId && + storedEnvironmentState.activityIdsByThreadId === state.activityIdsByThreadId && + storedEnvironmentState.activityByThreadId === state.activityByThreadId && + storedEnvironmentState.proposedPlanIdsByThreadId === state.proposedPlanIdsByThreadId && + storedEnvironmentState.proposedPlanByThreadId === state.proposedPlanByThreadId && + storedEnvironmentState.turnDiffIdsByThreadId === state.turnDiffIdsByThreadId && + storedEnvironmentState.turnDiffSummaryByThreadId === state.turnDiffSummaryByThreadId && + storedEnvironmentState.sidebarThreadSummaryById === state.sidebarThreadSummaryById && + storedEnvironmentState.bootstrapComplete === state.bootstrapComplete + ) { + return storedEnvironmentState; + } + + return { + projectIds: state.projectIds, + projectById: state.projectById, + threadIds: state.threadIds, + threadIdsByProjectId: state.threadIdsByProjectId, + threadShellById: state.threadShellById, + threadSessionById: state.threadSessionById, + threadTurnStateById: state.threadTurnStateById, + messageIdsByThreadId: state.messageIdsByThreadId, + messageByThreadId: state.messageByThreadId, + activityIdsByThreadId: state.activityIdsByThreadId, + activityByThreadId: state.activityByThreadId, + proposedPlanIdsByThreadId: state.proposedPlanIdsByThreadId, + proposedPlanByThreadId: state.proposedPlanByThreadId, + turnDiffIdsByThreadId: state.turnDiffIdsByThreadId, + turnDiffSummaryByThreadId: state.turnDiffSummaryByThreadId, + sidebarThreadSummaryById: state.sidebarThreadSummaryById, + bootstrapComplete: state.bootstrapComplete, + }; } function projectActiveEnvironmentState(input: { @@ -1142,46 +1133,15 @@ export function syncServerReadModel( readModel: OrchestrationReadModel, environmentId: EnvironmentId, ): AppState { -<<<<<<< HEAD return commitEnvironmentState( state, environmentId, - syncEnvironmentReadModel(getStoredEnvironmentState(state, environmentId), readModel, environmentId), - ); -======= - const projectsForEnvironment = readModel.projects - .filter((project) => project.deletedAt === null) - .map((project) => mapProject(project, environmentId)); - const threadsForEnvironment = readModel.threads - .filter((thread) => thread.deletedAt === null) - .map((thread) => mapThread(thread, environmentId)); - const projects = [ - ...state.projects.filter((project) => !sameEnvironmentId(project.environmentId, environmentId)), - ...projectsForEnvironment, - ]; - const threads = [ - ...state.threads.filter((thread) => !sameEnvironmentId(thread.environmentId, environmentId)), - ...threadsForEnvironment, - ]; - const sidebarThreadsByScopedId = mergeSidebarThreadsByScopedId( - state.sidebarThreadsByScopedId, - environmentId, - threadsForEnvironment, - ); - const threadScopedIdsByProjectScopedId = mergeThreadScopedIdsByProjectScopedId( - state.threadScopedIdsByProjectScopedId, - environmentId, - threadsForEnvironment, + syncEnvironmentReadModel( + getStoredEnvironmentState(state, environmentId), + readModel, + environmentId, + ), ); - return { - ...state, - projects, - threads, - sidebarThreadsByScopedId, - threadScopedIdsByProjectScopedId, - bootstrapComplete: true, - }; ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) } function applyEnvironmentOrchestrationEvent( @@ -1191,16 +1151,19 @@ function applyEnvironmentOrchestrationEvent( ): EnvironmentState { switch (event.type) { case "project.created": { - const nextProject = mapProject({ - id: event.payload.projectId, - title: event.payload.title, - workspaceRoot: event.payload.workspaceRoot, - defaultModelSelection: event.payload.defaultModelSelection, - scripts: event.payload.scripts, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - deletedAt: null, - }, environmentId); + const nextProject = mapProject( + { + id: event.payload.projectId, + title: event.payload.title, + workspaceRoot: event.payload.workspaceRoot, + defaultModelSelection: event.payload.defaultModelSelection, + scripts: event.payload.scripts, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + deletedAt: null, + }, + environmentId, + ); const existingProjectId = state.projectIds.find( (projectId) => @@ -1281,26 +1244,29 @@ function applyEnvironmentOrchestrationEvent( case "thread.created": { const previousThread = getThread(state, event.payload.threadId); - const nextThread = mapThread({ - id: event.payload.threadId, - projectId: event.payload.projectId, - title: event.payload.title, - modelSelection: event.payload.modelSelection, - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - branch: event.payload.branch, - worktreePath: event.payload.worktreePath, - latestTurn: null, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - }, environmentId); + const nextThread = mapThread( + { + id: event.payload.threadId, + projectId: event.payload.projectId, + title: event.payload.title, + modelSelection: event.payload.modelSelection, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + latestTurn: null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + environmentId, + ); return writeThreadState(state, nextThread, previousThread); } @@ -1689,7 +1655,6 @@ export const selectProjectById = export const selectThreadById = (threadId: ThreadId | null | undefined) => (state: AppState): Thread | undefined => -<<<<<<< HEAD threadId ? getThread(state, threadId) : undefined; export const selectSidebarThreadSummaryById = (threadId: ThreadId | null | undefined) => @@ -1699,53 +1664,6 @@ export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => (state: AppState): ThreadId[] => projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; -======= - threadId - ? state.threads.find( - (thread) => - thread.id === threadId && - sameEnvironmentId(thread.environmentId, state.activeEnvironmentId), - ) - : undefined; - -export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => { - let cachedProjectScopedId: string | null = null; - let cachedScopedIds: string[] | undefined; - let cachedSidebarThreadsByScopedId: Record | undefined; - let cachedResult: ThreadId[] = EMPTY_THREAD_IDS; - - return (state: AppState): ThreadId[] => { - if (!projectId) { - return EMPTY_THREAD_IDS; - } - - const projectScopedId = getProjectScopedId({ - environmentId: state.activeEnvironmentId, - id: projectId, - }); - const scopedIds = state.threadScopedIdsByProjectScopedId[projectScopedId] ?? EMPTY_SCOPED_IDS; - const sidebarThreadsByScopedId = state.sidebarThreadsByScopedId; - - if ( - cachedProjectScopedId === projectScopedId && - cachedScopedIds === scopedIds && - cachedSidebarThreadsByScopedId === sidebarThreadsByScopedId - ) { - return cachedResult; - } - - const result = scopedIds - .map((scopedId) => sidebarThreadsByScopedId[scopedId]?.id ?? null) - .filter((threadId): threadId is ThreadId => threadId !== null); - - cachedProjectScopedId = projectScopedId; - cachedScopedIds = scopedIds; - cachedSidebarThreadsByScopedId = sidebarThreadsByScopedId; - cachedResult = result; - return result; - }; -}; ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { if (state.activeEnvironmentId === null) { @@ -1779,10 +1697,7 @@ export function applyOrchestrationEvent( ); } -export function setActiveEnvironmentId( - state: AppState, - environmentId: EnvironmentId, -): AppState { +export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { if (state.activeEnvironmentId === environmentId) { return state; } diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index be70a86b13..a7a7440eb2 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,4 +1,3 @@ -<<<<<<< HEAD import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; import { type AppState } from "./store"; import { @@ -11,12 +10,6 @@ import { type ThreadTurnState, type TurnDiffSummary, } from "./types"; -======= -import { type ThreadId } from "@t3tools/contracts"; -import { useMemo } from "react"; -import { selectProjectById, selectThreadById, useStore } from "./store"; -import { type Project, type Thread } from "./types"; ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) const EMPTY_MESSAGES: ChatMessage[] = []; const EMPTY_ACTIVITIES: Thread["activities"] = []; @@ -42,7 +35,6 @@ export function createProjectSelector( ): (state: AppState) => Project | undefined { return (state) => (projectId ? state.projectById[projectId] : undefined); } -<<<<<<< HEAD export function createSidebarThreadSummarySelector( threadId: ThreadId | null | undefined, @@ -152,5 +144,3 @@ export function createThreadSelector( return previousThread; }; } -======= ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs)