Unified · Split
| @@ -60,6 +60,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; · 59 unmodified lines | ||
| 60 | 60 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 61 | 61 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 62 | 62 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; |
| 63 | +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | |
| 63 | 64 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); |
| 64 | 65 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); |
| 65 | 66 | const DESKTOP_SCHEME = "t3"; |
| @@ -1172,6 +1173,14 @@ function registerIpcHandlers(): void { · 1106 unmodified lines | ||
| 1172 | 1173 | event.returnValue = backendWsUrl; |
| 1173 | 1174 | }); |
| 1174 | 1175 | |
| 1176 | + ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); | |
| 1177 | + ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { | |
| 1178 | + event.returnValue = { | |
| 1179 | + label: "Local environment", | |
| 1180 | + wsUrl: backendWsUrl || null, | |
| 1181 | + } as const; | |
| 1182 | + }); | |
| 1183 | + | |
| 1175 | 1184 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); |
| 1176 | 1185 | ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { |
| 1177 | 1186 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; |
| @@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; · 12 unmodified lines | ||
| 13 | 13 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 14 | 14 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 15 | 15 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; |
| 16 | +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | |
| 16 | 17 | |
| 17 | 18 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 18 | 19 | getWsUrl: () => { |
| 19 | 20 | const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); |
| 20 | 21 | return typeof result === "string" ? result : null; |
| 21 | 22 | }, |
| 23 | + getLocalEnvironmentBootstrap: () => { | |
| 24 | + const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); | |
| 25 | + if (typeof result !== "object" || result === null) { | |
| 26 | + return null; | |
| 27 | + } | |
| 28 | + return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>; | |
| 29 | + }, | |
| 22 | 30 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), |
| 23 | 31 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), |
| 24 | 32 | setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), |
| @@ -45,6 +45,7 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; · 44 unmodified lines | ||
| 45 | 45 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; |
| 46 | 46 | import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; |
| 47 | 47 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; |
| 48 | +import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts"; | |
| 48 | 49 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; |
| 49 | 50 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; |
| 50 | 51 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; |
| @@ -338,6 +339,7 @@ export const makeOrchestrationIntegrationHarness = ( · 287 unmodified lines | ||
| 338 | 339 | Layer.provideMerge(runtimeServicesLayer), |
| 339 | 340 | Layer.provideMerge(orchestrationReactorLayer), |
| 340 | 341 | Layer.provide(persistenceLayer), |
| 342 | + Layer.provideMerge(RepositoryIdentityResolverLive), | |
| 341 | 343 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 342 | 344 | Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), |
| 343 | 345 | Layer.provideMerge(NodeServices.layer), |
| @@ -30,6 +30,7 @@ export interface ServerDerivedPaths { · 29 unmodified lines | ||
| 30 | 30 | readonly providerEventLogPath: string; |
| 31 | 31 | readonly terminalLogsDir: string; |
| 32 | 32 | readonly anonymousIdPath: string; |
| 33 | + readonly environmentIdPath: string; | |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | /** |
| @@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* ( · 47 unmodified lines | ||
| 83 | 84 | providerEventLogPath: join(providerLogsDir, "events.log"), |
| 84 | 85 | terminalLogsDir: join(logsDir, "terminals"), |
| 85 | 86 | anonymousIdPath: join(stateDir, "anonymous-id"), |
| 87 | + environmentIdPath: join(stateDir, "environment-id"), | |
| 86 | 88 | }; |
| 87 | 89 | }); |
| 88 | 90 | |
| @@ -0,0 +1,124 @@ | ||
| 1 | +import * as nodePath from "node:path"; | |
| 2 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | +import { expect, it } from "@effect/vitest"; | |
| 4 | +import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; | |
| 5 | + | |
| 6 | +import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts"; | |
| 7 | +import { ServerEnvironment } from "../Services/ServerEnvironment.ts"; | |
| 8 | +import { ServerEnvironmentLive } from "./ServerEnvironment.ts"; | |
| 9 | + | |
| 10 | +const makeServerEnvironmentLayer = (baseDir: string) => | |
| 11 | + ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); | |
| 12 | + | |
| 13 | +const makeServerConfig = Effect.fn(function* (baseDir: string) { | |
| 14 | + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | |
| 15 | + | |
| 16 | + return { | |
| 17 | + ...derivedPaths, | |
| 18 | + logLevel: "Error", | |
| 19 | + traceMinLevel: "Info", | |
| 20 | + traceTimingEnabled: true, | |
| 21 | + traceBatchWindowMs: 200, | |
| 22 | + traceMaxBytes: 10 * 1024 * 1024, | |
| 23 | + traceMaxFiles: 10, | |
| 24 | + otlpTracesUrl: undefined, | |
| 25 | + otlpMetricsUrl: undefined, | |
| 26 | + otlpExportIntervalMs: 10_000, | |
| 27 | + otlpServiceName: "t3-server", | |
| 28 | + cwd: process.cwd(), | |
| 29 | + baseDir, | |
| 30 | + mode: "web", | |
| 31 | + autoBootstrapProjectFromCwd: false, | |
| 32 | + logWebSocketEvents: false, | |
| 33 | + port: 0, | |
| 34 | + host: undefined, | |
| 35 | + authToken: undefined, | |
| 36 | + staticDir: undefined, | |
| 37 | + devUrl: undefined, | |
| 38 | + noBrowser: false, | |
| 39 | + } satisfies ServerConfigShape; | |
| 40 | +}); | |
| 41 | + | |
| 42 | +it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { | |
| 43 | + it.effect("persists the environment id across service restarts", () => | |
| 44 | + Effect.gen(function* () { | |
| 45 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 46 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 47 | + prefix: "t3-server-environment-test-", | |
| 48 | + }); | |
| 49 | + | |
| 50 | + const first = yield* Effect.gen(function* () { | |
| 51 | + const serverEnvironment = yield* ServerEnvironment; | |
| 52 | + return yield* serverEnvironment.getDescriptor; | |
| 53 | + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); | |
| 54 | + const second = yield* Effect.gen(function* () { | |
| 55 | + const serverEnvironment = yield* ServerEnvironment; | |
| 56 | + return yield* serverEnvironment.getDescriptor; | |
| 57 | + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); | |
| 58 | + | |
| 59 | + expect(first.environmentId).toBe(second.environmentId); | |
| 60 | + expect(second.capabilities.repositoryIdentity).toBe(true); | |
| 61 | + }), | |
| 62 | + ); | |
| 63 | + | |
| 64 | + it.effect("fails instead of overwriting a persisted id when reading the file errors", () => | |
| 65 | + Effect.gen(function* () { | |
| 66 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 67 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 68 | + prefix: "t3-server-environment-read-error-test-", | |
| 69 | + }); | |
| 70 | + const serverConfig = yield* makeServerConfig(baseDir); | |
| 71 | + const environmentIdPath = serverConfig.environmentIdPath; | |
| 72 | + yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true }); | |
| 73 | + yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); | |
| 74 | + const writeAttempts: string[] = []; | |
| 75 | + const failingFileSystemLayer = FileSystem.layerNoop({ | |
| 76 | + exists: (path) => Effect.succeed(path === environmentIdPath), | |
| 77 | + readFileString: (path) => | |
| 78 | + path === environmentIdPath | |
| 79 | + ? Effect.fail( | |
| 80 | + PlatformError.systemError({ | |
| 81 | + _tag: "PermissionDenied", | |
| 82 | + module: "FileSystem", | |
| 83 | + method: "readFileString", | |
| 84 | + description: "permission denied", | |
| 85 | + pathOrDescriptor: path, | |
| 86 | + }), | |
| 87 | + ) | |
| 88 | + : Effect.fail( | |
| 89 | + PlatformError.systemError({ | |
| 90 | + _tag: "NotFound", | |
| 91 | + module: "FileSystem", | |
| 92 | + method: "readFileString", | |
| 93 | + description: "not found", | |
| 94 | + pathOrDescriptor: path, | |
| 95 | + }), | |
| 96 | + ), | |
| 97 | + writeFileString: (path) => { | |
| 98 | + writeAttempts.push(path); | |
| 99 | + return Effect.void; | |
| 100 | + }, | |
| 101 | + }); | |
| 102 | + | |
| 103 | + const exit = yield* Effect.gen(function* () { | |
| 104 | + const serverEnvironment = yield* ServerEnvironment; | |
| 105 | + return yield* serverEnvironment.getDescriptor; | |
| 106 | + }).pipe( | |
| 107 | + Effect.provide( | |
| 108 | + ServerEnvironmentLive.pipe( | |
| 109 | + Layer.provide( | |
| 110 | + Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer), | |
| 111 | + ), | |
| 112 | + ), | |
| 113 | + ), | |
| 114 | + Effect.exit, | |
| 115 | + ); | |
| 116 | + | |
| 117 | + expect(Exit.isFailure(exit)).toBe(true); | |
| 118 | + expect(writeAttempts).toEqual([]); | |
| 119 | + expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( | |
| 120 | + "persisted-environment-id\n", | |
| 121 | + ); | |
| 122 | + }), | |
| 123 | + ); | |
| 124 | +}); | |
| @@ -0,0 +1,94 @@ | ||
| 1 | +import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | |
| 2 | +import { Effect, FileSystem, Layer, Path, Random } from "effect"; | |
| 3 | + | |
| 4 | +import { ServerConfig } from "../../config.ts"; | |
| 5 | +import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; | |
| 6 | +import { version } from "../../../package.json" with { type: "json" }; | |
| 7 | + | |
| 8 | +function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { | |
| 9 | + switch (process.platform) { | |
| 10 | + case "darwin": | |
| 11 | + return "darwin"; | |
| 12 | + case "linux": | |
| 13 | + return "linux"; | |
| 14 | + case "win32": | |
| 15 | + return "windows"; | |
| 16 | + default: | |
| 17 | + return "unknown"; | |
| 18 | + } | |
| 19 | +} | |
| 20 | + | |
| 21 | +function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { | |
| 22 | + switch (process.arch) { | |
| 23 | + case "arm64": | |
| 24 | + return "arm64"; | |
| 25 | + case "x64": | |
| 26 | + return "x64"; | |
| 27 | + default: | |
| 28 | + return "other"; | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { | |
| 33 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 34 | + const path = yield* Path.Path; | |
| 35 | + const serverConfig = yield* ServerConfig; | |
| 36 | + | |
| 37 | + const readPersistedEnvironmentId = Effect.gen(function* () { | |
| 38 | + const exists = yield* fileSystem | |
| 39 | + .exists(serverConfig.environmentIdPath) | |
| 40 | + .pipe(Effect.orElseSucceed(() => false)); | |
| 41 | + if (!exists) { | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + | |
| 45 | + const raw = yield* fileSystem | |
| 46 | + .readFileString(serverConfig.environmentIdPath) | |
| 47 | + .pipe(Effect.map((value) => value.trim())); | |
| 48 | + | |
| 49 | + return raw.length > 0 ? raw : null; | |
| 50 | + }); | |
| 51 | + | |
| 52 | + const persistEnvironmentId = (value: string) => | |
| 53 | + fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); | |
| 54 | + | |
| 55 | + const environmentIdRaw = yield* Effect.gen(function* () { | |
| 56 | + const persisted = yield* readPersistedEnvironmentId; | |
| 57 | + if (persisted) { | |
| 58 | + return persisted; | |
| 59 | + } | |
| 60 | + | |
| 61 | + const generated = yield* Random.nextUUIDv4; | |
| 62 | + yield* persistEnvironmentId(generated); | |
| 63 | + return generated; | |
| 64 | + }); | |
| 65 | + | |
| 66 | + const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw); | |
| 67 | + const cwdBaseName = path.basename(serverConfig.cwd).trim(); | |
| 68 | + const label = | |
| 69 | + serverConfig.mode === "desktop" | |
| 70 | + ? "Local environment" | |
| 71 | + : cwdBaseName.length > 0 | |
| 72 | + ? cwdBaseName | |
| 73 | + : "T3 environment"; | |
| 74 | + | |
| 75 | + const descriptor: ExecutionEnvironmentDescriptor = { | |
| 76 | + environmentId, | |
| 77 | + label, | |
| 78 | + platform: { | |
| 79 | + os: platformOs(), | |
| 80 | + arch: platformArch(), | |
| 81 | + }, | |
| 82 | + serverVersion: version, | |
| 83 | + capabilities: { | |
| 84 | + repositoryIdentity: true, | |
| 85 | + }, | |
| 86 | + }; | |
| 87 | + | |
| 88 | + return { | |
| 89 | + getEnvironmentId: Effect.succeed(environmentId), | |
| 90 | + getDescriptor: Effect.succeed(descriptor), | |
| 91 | + } satisfies ServerEnvironmentShape; | |
| 92 | +}); | |
| 93 | + | |
| 94 | +export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()); | |
| @@ -0,0 +1,13 @@ | ||
| 1 | +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | |
| 2 | +import { ServiceMap } from "effect"; | |
| 3 | +import type { Effect } from "effect"; | |
| 4 | + | |
| 5 | +export interface ServerEnvironmentShape { | |
| 6 | + readonly getEnvironmentId: Effect.Effect<EnvironmentId>; | |
| 7 | + readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>; | |
| 8 | +} | |
| 9 | + | |
| 10 | +export class ServerEnvironment extends ServiceMap.Service< | |
| 11 | + ServerEnvironment, | |
| 12 | + ServerEnvironmentShape | |
| 13 | +>()("t3/environment/Services/ServerEnvironment") {} | |
| @@ -854,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 853 unmodified lines | ||
| 854 | 854 | "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", |
| 855 | 855 | ); |
| 856 | 856 | }), |
| 857 | − 12_000, | |
| 857 | + 20_000, | |
| 858 | 858 | ); |
| 859 | 859 | |
| 860 | 860 | it.effect( |
| @@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 101 unmodified lines | ||
| 962 | 962 | ), |
| 963 | 963 | ).toBe(false); |
| 964 | 964 | }), |
| 965 | − 12_000, | |
| 965 | + 20_000, | |
| 966 | 966 | ); |
| 967 | 967 | |
| 968 | 968 | it.effect("status returns merged PR state when latest PR was merged", () => |
| @@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 716 unmodified lines | ||
| 1685 | 1685 | false, |
| 1686 | 1686 | ); |
| 1687 | 1687 | }), |
| 1688 | − 12_000, | |
| 1688 | + 20_000, | |
| 1689 | 1689 | ); |
| 1690 | 1690 | |
| 1691 | 1691 | it.effect( |
| @@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; · 19 unmodified lines | ||
| 20 | 20 | import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; |
| 21 | 21 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; |
| 22 | 22 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; |
| 23 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 23 | 24 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; |
| 24 | 25 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 25 | 26 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| @@ -256,6 +257,7 @@ describe("CheckpointReactor", () => { · 230 unmodified lines | ||
| 256 | 257 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 257 | 258 | Layer.provide(OrchestrationEventStoreLive), |
| 258 | 259 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 260 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 259 | 261 | Layer.provide(SqlitePersistenceMemory), |
| 260 | 262 | ); |
| 261 | 263 | |
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | OrchestrationEventStore, |
| 20 | 20 | type OrchestrationEventStoreShape, |
| 21 | 21 | } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 22 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 22 | 23 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 23 | 24 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 24 | 25 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| @@ -45,6 +46,7 @@ async function createOrchestrationSystem() { · 20 unmodified lines | ||
| 45 | 46 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 46 | 47 | Layer.provide(OrchestrationEventStoreLive), |
| 47 | 48 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 49 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 48 | 50 | Layer.provide(SqlitePersistenceMemory), |
| 49 | 51 | Layer.provideMerge(ServerConfigLayer), |
| 50 | 52 | Layer.provideMerge(NodeServices.layer), |
| @@ -623,6 +625,7 @@ describe("OrchestrationEngine", () => { · 572 unmodified lines | ||
| 623 | 625 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 624 | 626 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), |
| 625 | 627 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 628 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 626 | 629 | Layer.provide(SqlitePersistenceMemory), |
| 627 | 630 | Layer.provideMerge(ServerConfigLayer), |
| 628 | 631 | Layer.provideMerge(NodeServices.layer), |
| @@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => { · 90 unmodified lines | ||
| 719 | 722 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 720 | 723 | Layer.provide(OrchestrationEventStoreLive), |
| 721 | 724 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 725 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 722 | 726 | Layer.provide(SqlitePersistenceMemory), |
| 723 | 727 | ), |
| 724 | 728 | ); |
| @@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => { · 136 unmodified lines | ||
| 861 | 865 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 862 | 866 | Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), |
| 863 | 867 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 868 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 864 | 869 | Layer.provide(SqlitePersistenceMemory), |
| 865 | 870 | ), |
| 866 | 871 | ); |
| @@ -20,6 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | SqlitePersistenceMemory, |
| 21 | 21 | } from "../../persistence/Layers/Sqlite.ts"; |
| 22 | 22 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 23 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 23 | 24 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 24 | 25 | import { |
| 25 | 26 | ORCHESTRATION_PROJECTOR_NAMES, |
| @@ -1846,6 +1847,7 @@ const engineLayer = it.layer( · 1820 unmodified lines | ||
| 1846 | 1847 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 1847 | 1848 | Layer.provide(OrchestrationEventStoreLive), |
| 1848 | 1849 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 1850 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 1849 | 1851 | Layer.provideMerge(SqlitePersistenceMemory), |
| 1850 | 1852 | Layer.provideMerge( |
| 1851 | 1853 | ServerConfig.layerTest(process.cwd(), { |
| @@ -4,6 +4,7 @@ import { Effect, Layer } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 5 | 5 | |
| 6 | 6 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 7 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 7 | 8 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 8 | 9 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 9 | 10 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| @@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value); · 5 unmodified lines | ||
| 15 | 16 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); |
| 16 | 17 | |
| 17 | 18 | const projectionSnapshotLayer = it.layer( |
| 18 | − OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | |
| 19 | + OrchestrationProjectionSnapshotQueryLive.pipe( | |
| 20 | + Layer.provideMerge(RepositoryIdentityResolverLive), | |
| 21 | + Layer.provideMerge(SqlitePersistenceMemory), | |
| 22 | + ), | |
| 19 | 23 | ); |
| 20 | 24 | |
| 21 | 25 | projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| @@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 212 unmodified lines | ||
| 234 | 238 | id: asProjectId("project-1"), |
| 235 | 239 | title: "Project 1", |
| 236 | 240 | workspaceRoot: "/tmp/project-1", |
| 241 | + repositoryIdentity: null, | |
| 237 | 242 | defaultModelSelection: { |
| 238 | 243 | provider: "codex", |
| 239 | 244 | model: "gpt-5-codex", |
| @@ -38,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh · 37 unmodified lines | ||
| 38 | 38 | import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; |
| 39 | 39 | import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; |
| 40 | 40 | import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; |
| 41 | +import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; | |
| 41 | 42 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 42 | 43 | import { |
| 43 | 44 | ProjectionSnapshotQuery, |
| @@ -163,6 +164,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st · 119 unmodified lines | ||
| 163 | 164 | |
| 164 | 165 | const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 165 | 166 | const sql = yield* SqlClient.SqlClient; |
| 167 | + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | |
| 168 | + const repositoryIdentityResolutionConcurrency = 4; | |
| 166 | 169 | |
| 167 | 170 | const listProjectRows = SqlSchema.findAll({ |
| 168 | 171 | Request: Schema.Void, |
| @@ -436,269 +439,283 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 267 unmodified lines | ||
| 436 | 439 | const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => |
| 437 | 440 | sql |
| 438 | 441 | .withTransaction( |
| 439 | − Effect.gen(function* () { | |
| 440 | − const [ | |
| 441 | − projectRows, | |
| 442 | − threadRows, | |
| 443 | − messageRows, | |
| 444 | − proposedPlanRows, | |
| 445 | − activityRows, | |
| 446 | − sessionRows, | |
| 447 | − checkpointRows, | |
| 448 | − latestTurnRows, | |
| 449 | − stateRows, | |
| 450 | − ] = yield* Effect.all([ | |
| 451 | − listProjectRows(undefined).pipe( | |
| 452 | − Effect.mapError( | |
| 453 | − toPersistenceSqlOrDecodeError( | |
| 454 | − "ProjectionSnapshotQuery.getSnapshot:listProjects:query", | |
| 455 | − "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", | |
| 456 | − ), | |
| 457 | − ), | |
| 458 | − ), | |
| 459 | − listThreadRows(undefined).pipe( | |
| 460 | − Effect.mapError( | |
| 461 | − toPersistenceSqlOrDecodeError( | |
| 462 | − "ProjectionSnapshotQuery.getSnapshot:listThreads:query", | |
| 463 | − "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", | |
| 464 | − ), | |
| 442 | + Effect.all([ | |
| 443 | + listProjectRows(undefined).pipe( | |
| 444 | + Effect.mapError( | |
| 445 | + toPersistenceSqlOrDecodeError( | |
| 446 | + "ProjectionSnapshotQuery.getSnapshot:listProjects:query", | |
| 447 | + "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", | |
| 465 | 448 | ), |
| 466 | 449 | ), |
| 467 | − listThreadMessageRows(undefined).pipe( | |
| 468 | − Effect.mapError( | |
| 469 | − toPersistenceSqlOrDecodeError( | |
| 470 | − "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", | |
| 471 | − "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", | |
| 472 | − ), | |
| 450 | + ), | |
| 451 | + listThreadRows(undefined).pipe( | |
| 452 | + Effect.mapError( | |
| 453 | + toPersistenceSqlOrDecodeError( | |
| 454 | + "ProjectionSnapshotQuery.getSnapshot:listThreads:query", | |
| 455 | + "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", | |
| 473 | 456 | ), |
| 474 | 457 | ), |
| 475 | − listThreadProposedPlanRows(undefined).pipe( | |
| 476 | − Effect.mapError( | |
| 477 | − toPersistenceSqlOrDecodeError( | |
| 478 | − "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", | |
| 479 | − "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", | |
| 480 | − ), | |
| 458 | + ), | |
| 459 | + listThreadMessageRows(undefined).pipe( | |
| 460 | + Effect.mapError( | |
| 461 | + toPersistenceSqlOrDecodeError( | |
| 462 | + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", | |
| 463 | + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", | |
| 481 | 464 | ), |
| 482 | 465 | ), |
| 483 | − listThreadActivityRows(undefined).pipe( | |
| 484 | − Effect.mapError( | |
| 485 | − toPersistenceSqlOrDecodeError( | |
| 486 | − "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", | |
| 487 | − "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", | |
| 488 | − ), | |
| 466 | + ), | |
| 467 | + listThreadProposedPlanRows(undefined).pipe( | |
| 468 | + Effect.mapError( | |
| 469 | + toPersistenceSqlOrDecodeError( | |
| 470 | + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", | |
| 471 | + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", | |
| 489 | 472 | ), |
| 490 | 473 | ), |
| 491 | − listThreadSessionRows(undefined).pipe( | |
| 492 | − Effect.mapError( | |
| 493 | − toPersistenceSqlOrDecodeError( | |
| 494 | − "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", | |
| 495 | − "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", | |
| 496 | − ), | |
| 474 | + ), | |
| 475 | + listThreadActivityRows(undefined).pipe( | |
| 476 | + Effect.mapError( | |
| 477 | + toPersistenceSqlOrDecodeError( | |
| 478 | + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", | |
| 479 | + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", | |
| 497 | 480 | ), |
| 498 | 481 | ), |
| 499 | − listCheckpointRows(undefined).pipe( | |
| 500 | − Effect.mapError( | |
| 501 | − toPersistenceSqlOrDecodeError( | |
| 502 | − "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", | |
| 503 | − "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", | |
| 504 | − ), | |
| 482 | + ), | |
| 483 | + listThreadSessionRows(undefined).pipe( | |
| 484 | + Effect.mapError( | |
| 485 | + toPersistenceSqlOrDecodeError( | |
| 486 | + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", | |
| 487 | + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", | |
| 505 | 488 | ), |
| 506 | 489 | ), |
| 507 | − listLatestTurnRows(undefined).pipe( | |
| 508 | − Effect.mapError( | |
| 509 | − toPersistenceSqlOrDecodeError( | |
| 510 | − "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", | |
| 511 | − "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", | |
| 512 | − ), | |
| 490 | + ), | |
| 491 | + listCheckpointRows(undefined).pipe( | |
| 492 | + Effect.mapError( | |
| 493 | + toPersistenceSqlOrDecodeError( | |
| 494 | + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", | |
| 495 | + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", | |
| 513 | 496 | ), |
| 514 | 497 | ), |
| 515 | − listProjectionStateRows(undefined).pipe( | |
| 516 | − Effect.mapError( | |
| 517 | − toPersistenceSqlOrDecodeError( | |
| 518 | − "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", | |
| 519 | − "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", | |
| 520 | − ), | |
| 498 | + ), | |
| 499 | + listLatestTurnRows(undefined).pipe( | |
| 500 | + Effect.mapError( | |
| 501 | + toPersistenceSqlOrDecodeError( | |
| 502 | + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", | |
| 503 | + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", | |
| 521 | 504 | ), |
| 522 | 505 | ), |
| 523 | − ]); | |
| 524 | − | |
| 525 | − const messagesByThread = new Map<string, Array<OrchestrationMessage>>(); | |
| 526 | − const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>(); | |
| 527 | − const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>(); | |
| 528 | − const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>(); | |
| 529 | − const sessionsByThread = new Map<string, OrchestrationSession>(); | |
| 530 | − const latestTurnByThread = new Map<string, OrchestrationLatestTurn>(); | |
| 531 | − | |
| 532 | − let updatedAt: string | null = null; | |
| 533 | − | |
| 534 | − for (const row of projectRows) { | |
| 535 | − updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 536 | − } | |
| 537 | − for (const row of threadRows) { | |
| 538 | − updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 539 | − } | |
| 540 | − for (const row of stateRows) { | |
| 541 | − updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 542 | − } | |
| 543 | − | |
| 544 | − for (const row of messageRows) { | |
| 545 | − updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 546 | − const threadMessages = messagesByThread.get(row.threadId) ?? []; | |
| 547 | − threadMessages.push({ | |
| 548 | − id: row.messageId, | |
| 549 | − role: row.role, | |
| 550 | − text: row.text, | |
| 551 | − ...(row.attachments !== null ? { attachments: row.attachments } : {}), | |
| 552 | − turnId: row.turnId, | |
| 553 | − streaming: row.isStreaming === 1, | |
| 554 | − createdAt: row.createdAt, | |
| 555 | − updatedAt: row.updatedAt, | |
| 556 | − }); | |
| 557 | − messagesByThread.set(row.threadId, threadMessages); | |
| 558 | − } | |
| 559 | − | |
| 560 | − for (const row of proposedPlanRows) { | |
| 561 | − updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 562 | − const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; | |
| 563 | − threadProposedPlans.push({ | |
| 564 | − id: row.planId, | |
| 565 | − turnId: row.turnId, | |
| 566 | − planMarkdown: row.planMarkdown, | |
| 567 | − implementedAt: row.implementedAt, | |
| 568 | − implementationThreadId: row.implementationThreadId, | |
| 569 | − createdAt: row.createdAt, | |
| 570 | − updatedAt: row.updatedAt, | |
| 571 | − }); | |
| 572 | − proposedPlansByThread.set(row.threadId, threadProposedPlans); | |
| 573 | − } | |
| 574 | − | |
| 575 | − for (const row of activityRows) { | |
| 576 | − updatedAt = maxIso(updatedAt, row.createdAt); | |
| 577 | − const threadActivities = activitiesByThread.get(row.threadId) ?? []; | |
| 578 | − threadActivities.push({ | |
| 579 | − id: row.activityId, | |
| 580 | − tone: row.tone, | |
| 581 | − kind: row.kind, | |
| 582 | − summary: row.summary, | |
| 583 | − payload: row.payload, | |
| 584 | − turnId: row.turnId, | |
| 585 | − ...(row.sequence !== null ? { sequence: row.sequence } : {}), | |
| 586 | − createdAt: row.createdAt, | |
| 587 | − }); | |
| 588 | − activitiesByThread.set(row.threadId, threadActivities); | |
| 589 | − } | |
| 590 | − | |
| 591 | − for (const row of checkpointRows) { | |
| 592 | − updatedAt = maxIso(updatedAt, row.completedAt); | |
| 593 | − const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; | |
| 594 | − threadCheckpoints.push({ | |
| 595 | − turnId: row.turnId, | |
| 596 | − checkpointTurnCount: row.checkpointTurnCount, | |
| 597 | − checkpointRef: row.checkpointRef, | |
| 598 | − status: row.status, | |
| 599 | − files: row.files, | |
| 600 | − assistantMessageId: row.assistantMessageId, | |
| 601 | − completedAt: row.completedAt, | |
| 602 | − }); | |
| 603 | − checkpointsByThread.set(row.threadId, threadCheckpoints); | |
| 604 | − } | |
| 605 | − | |
| 606 | − for (const row of latestTurnRows) { | |
| 607 | − updatedAt = maxIso(updatedAt, row.requestedAt); | |
| 608 | − if (row.startedAt !== null) { | |
| 609 | − updatedAt = maxIso(updatedAt, row.startedAt); | |
| 610 | − } | |
| 611 | − if (row.completedAt !== null) { | |
| 612 | − updatedAt = maxIso(updatedAt, row.completedAt); | |
| 613 | − } | |
| 614 | − if (latestTurnByThread.has(row.threadId)) { | |
| 615 | − continue; | |
| 616 | − } | |
| 617 | − latestTurnByThread.set(row.threadId, { | |
| 618 | − turnId: row.turnId, | |
| 619 | − state: | |
| 620 | − row.state === "error" | |
| 621 | − ? "error" | |
| 622 | − : row.state === "interrupted" | |
| 623 | − ? "interrupted" | |
| 624 | − : row.state === "completed" | |
| 625 | − ? "completed" | |
| 626 | − : "running", | |
| 627 | − requestedAt: row.requestedAt, | |
| 628 | − startedAt: row.startedAt, | |
| 629 | − completedAt: row.completedAt, | |
| 630 | − assistantMessageId: row.assistantMessageId, | |
| 631 | − ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null | |
| 632 | − ? { | |
| 633 | − sourceProposedPlan: { | |
| 634 | − threadId: row.sourceProposedPlanThreadId, | |
| 635 | − planId: row.sourceProposedPlanId, | |
| 636 | − }, | |
| 637 | − } | |
| 638 | − : {}), | |
| 639 | − }); | |
| 640 | − } | |
| 641 | − | |
| 642 | − for (const row of sessionRows) { | |
| 643 | − updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 644 | − sessionsByThread.set(row.threadId, { | |
| 645 | − threadId: row.threadId, | |
| 646 | − status: row.status, | |
| 647 | − providerName: row.providerName, | |
| 648 | − runtimeMode: row.runtimeMode, | |
| 649 | − activeTurnId: row.activeTurnId, | |
| 650 | − lastError: row.lastError, | |
| 651 | − updatedAt: row.updatedAt, | |
| 652 | − }); | |
| 653 | − } | |
| 654 | − | |
| 655 | − const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({ | |
| 656 | − id: row.projectId, | |
| 657 | − title: row.title, | |
| 658 | − workspaceRoot: row.workspaceRoot, | |
| 659 | − defaultModelSelection: row.defaultModelSelection, | |
| 660 | − scripts: row.scripts, | |
| 661 | − createdAt: row.createdAt, | |
| 662 | − updatedAt: row.updatedAt, | |
| 663 | − deletedAt: row.deletedAt, | |
| 664 | − })); | |
| 665 | − | |
| 666 | − const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({ | |
| 667 | − id: row.threadId, | |
| 668 | − projectId: row.projectId, | |
| 669 | − title: row.title, | |
| 670 | − modelSelection: row.modelSelection, | |
| 671 | − runtimeMode: row.runtimeMode, | |
| 672 | − interactionMode: row.interactionMode, | |
| 673 | − branch: row.branch, | |
| 674 | − worktreePath: row.worktreePath, | |
| 675 | − latestTurn: latestTurnByThread.get(row.threadId) ?? null, | |
| 676 | − createdAt: row.createdAt, | |
| 677 | − updatedAt: row.updatedAt, | |
| 678 | − archivedAt: row.archivedAt, | |
| 679 | − deletedAt: row.deletedAt, | |
| 680 | − messages: messagesByThread.get(row.threadId) ?? [], | |
| 681 | − proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], | |
| 682 | − activities: activitiesByThread.get(row.threadId) ?? [], | |
| 683 | − checkpoints: checkpointsByThread.get(row.threadId) ?? [], | |
| 684 | − session: sessionsByThread.get(row.threadId) ?? null, | |
| 685 | − })); | |
| 686 | − | |
| 687 | − const snapshot = { | |
| 688 | − snapshotSequence: computeSnapshotSequence(stateRows), | |
| 689 | − projects, | |
| 690 | − threads, | |
| 691 | − updatedAt: updatedAt ?? new Date(0).toISOString(), | |
| 692 | − }; | |
| 693 | − | |
| 694 | − return yield* decodeReadModel(snapshot).pipe( | |
| 506 | + ), | |
| 507 | + listProjectionStateRows(undefined).pipe( | |
| 695 | 508 | Effect.mapError( |
| 696 | − toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), | |
| 509 | + toPersistenceSqlOrDecodeError( | |
| 510 | + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", | |
| 511 | + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", | |
| 512 | + ), | |
| 697 | 513 | ), |
| 698 | − ); | |
| 699 | − }), | |
| 514 | + ), | |
| 515 | + ]), | |
| 700 | 516 | ) |
| 701 | 517 | .pipe( |
| 518 | + Effect.flatMap( | |
| 519 | + ([ | |
| 520 | + projectRows, | |
| 521 | + threadRows, | |
| 522 | + messageRows, | |
| 523 | + proposedPlanRows, | |
| 524 | + activityRows, | |
| 525 | + sessionRows, | |
| 526 | + checkpointRows, | |
| 527 | + latestTurnRows, | |
| 528 | + stateRows, | |
| 529 | + ]) => | |
| 530 | + Effect.gen(function* () { | |
| 531 | + const messagesByThread = new Map<string, Array<OrchestrationMessage>>(); | |
| 532 | + const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>(); | |
| 533 | + const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>(); | |
| 534 | + const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>(); | |
| 535 | + const sessionsByThread = new Map<string, OrchestrationSession>(); | |
| 536 | + const latestTurnByThread = new Map<string, OrchestrationLatestTurn>(); | |
| 537 | + | |
| 538 | + let updatedAt: string | null = null; | |
| 539 | + | |
| 540 | + for (const row of projectRows) { | |
| 541 | + updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 542 | + } | |
| 543 | + for (const row of threadRows) { | |
| 544 | + updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 545 | + } | |
| 546 | + for (const row of stateRows) { | |
| 547 | + updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 548 | + } | |
| 549 | + | |
| 550 | + for (const row of messageRows) { | |
| 551 | + updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 552 | + const threadMessages = messagesByThread.get(row.threadId) ?? []; | |
| 553 | + threadMessages.push({ | |
| 554 | + id: row.messageId, | |
| 555 | + role: row.role, | |
| 556 | + text: row.text, | |
| 557 | + ...(row.attachments !== null ? { attachments: row.attachments } : {}), | |
| 558 | + turnId: row.turnId, | |
| 559 | + streaming: row.isStreaming === 1, | |
| 560 | + createdAt: row.createdAt, | |
| 561 | + updatedAt: row.updatedAt, | |
| 562 | + }); | |
| 563 | + messagesByThread.set(row.threadId, threadMessages); | |
| 564 | + } | |
| 565 | + | |
| 566 | + for (const row of proposedPlanRows) { | |
| 567 | + updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 568 | + const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; | |
| 569 | + threadProposedPlans.push({ | |
| 570 | + id: row.planId, | |
| 571 | + turnId: row.turnId, | |
| 572 | + planMarkdown: row.planMarkdown, | |
| 573 | + implementedAt: row.implementedAt, | |
| 574 | + implementationThreadId: row.implementationThreadId, | |
| 575 | + createdAt: row.createdAt, | |
| 576 | + updatedAt: row.updatedAt, | |
| 577 | + }); | |
| 578 | + proposedPlansByThread.set(row.threadId, threadProposedPlans); | |
| 579 | + } | |
| 580 | + | |
| 581 | + for (const row of activityRows) { | |
| 582 | + updatedAt = maxIso(updatedAt, row.createdAt); | |
| 583 | + const threadActivities = activitiesByThread.get(row.threadId) ?? []; | |
| 584 | + threadActivities.push({ | |
| 585 | + id: row.activityId, | |
| 586 | + tone: row.tone, | |
| 587 | + kind: row.kind, | |
| 588 | + summary: row.summary, | |
| 589 | + payload: row.payload, | |
| 590 | + turnId: row.turnId, | |
| 591 | + ...(row.sequence !== null ? { sequence: row.sequence } : {}), | |
| 592 | + createdAt: row.createdAt, | |
| 593 | + }); | |
| 594 | + activitiesByThread.set(row.threadId, threadActivities); | |
| 595 | + } | |
| 596 | + | |
| 597 | + for (const row of checkpointRows) { | |
| 598 | + updatedAt = maxIso(updatedAt, row.completedAt); | |
| 599 | + const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; | |
| 600 | + threadCheckpoints.push({ | |
| 601 | + turnId: row.turnId, | |
| 602 | + checkpointTurnCount: row.checkpointTurnCount, | |
| 603 | + checkpointRef: row.checkpointRef, | |
| 604 | + status: row.status, | |
| 605 | + files: row.files, | |
| 606 | + assistantMessageId: row.assistantMessageId, | |
| 607 | + completedAt: row.completedAt, | |
| 608 | + }); | |
| 609 | + checkpointsByThread.set(row.threadId, threadCheckpoints); | |
| 610 | + } | |
| 611 | + | |
| 612 | + for (const row of latestTurnRows) { | |
| 613 | + updatedAt = maxIso(updatedAt, row.requestedAt); | |
| 614 | + if (row.startedAt !== null) { | |
| 615 | + updatedAt = maxIso(updatedAt, row.startedAt); | |
| 616 | + } | |
| 617 | + if (row.completedAt !== null) { | |
| 618 | + updatedAt = maxIso(updatedAt, row.completedAt); | |
| 619 | + } | |
| 620 | + if (latestTurnByThread.has(row.threadId)) { | |
| 621 | + continue; | |
| 622 | + } | |
| 623 | + latestTurnByThread.set(row.threadId, { | |
| 624 | + turnId: row.turnId, | |
| 625 | + state: | |
| 626 | + row.state === "error" | |
| 627 | + ? "error" | |
| 628 | + : row.state === "interrupted" | |
| 629 | + ? "interrupted" | |
| 630 | + : row.state === "completed" | |
| 631 | + ? "completed" | |
| 632 | + : "running", | |
| 633 | + requestedAt: row.requestedAt, | |
| 634 | + startedAt: row.startedAt, | |
| 635 | + completedAt: row.completedAt, | |
| 636 | + assistantMessageId: row.assistantMessageId, | |
| 637 | + ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null | |
| 638 | + ? { | |
| 639 | + sourceProposedPlan: { | |
| 640 | + threadId: row.sourceProposedPlanThreadId, | |
| 641 | + planId: row.sourceProposedPlanId, | |
| 642 | + }, | |
| 643 | + } | |
| 644 | + : {}), | |
| 645 | + }); | |
| 646 | + } | |
| 647 | + | |
| 648 | + for (const row of sessionRows) { | |
| 649 | + updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 650 | + sessionsByThread.set(row.threadId, { | |
| 651 | + threadId: row.threadId, | |
| 652 | + status: row.status, | |
| 653 | + providerName: row.providerName, | |
| 654 | + runtimeMode: row.runtimeMode, | |
| 655 | + activeTurnId: row.activeTurnId, | |
| 656 | + lastError: row.lastError, | |
| 657 | + updatedAt: row.updatedAt, | |
| 658 | + }); | |
| 659 | + } | |
| 660 | + | |
| 661 | + const repositoryIdentities = new Map( | |
| 662 | + yield* Effect.forEach( | |
| 663 | + projectRows, | |
| 664 | + (row) => | |
| 665 | + repositoryIdentityResolver | |
| 666 | + .resolve(row.workspaceRoot) | |
| 667 | + .pipe(Effect.map((identity) => [row.projectId, identity] as const)), | |
| 668 | + { concurrency: repositoryIdentityResolutionConcurrency }, | |
| 669 | + ), | |
| 670 | + ); | |
| 671 | + | |
| 672 | + const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({ | |
| 673 | + id: row.projectId, | |
| 674 | + title: row.title, | |
| 675 | + workspaceRoot: row.workspaceRoot, | |
| 676 | + repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, | |
| 677 | + defaultModelSelection: row.defaultModelSelection, | |
| 678 | + scripts: row.scripts, | |
| 679 | + createdAt: row.createdAt, | |
| 680 | + updatedAt: row.updatedAt, | |
| 681 | + deletedAt: row.deletedAt, | |
| 682 | + })); | |
| 683 | + | |
| 684 | + const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({ | |
| 685 | + id: row.threadId, | |
| 686 | + projectId: row.projectId, | |
| 687 | + title: row.title, | |
| 688 | + modelSelection: row.modelSelection, | |
| 689 | + runtimeMode: row.runtimeMode, | |
| 690 | + interactionMode: row.interactionMode, | |
| 691 | + branch: row.branch, | |
| 692 | + worktreePath: row.worktreePath, | |
| 693 | + latestTurn: latestTurnByThread.get(row.threadId) ?? null, | |
| 694 | + createdAt: row.createdAt, | |
| 695 | + updatedAt: row.updatedAt, | |
| 696 | + archivedAt: row.archivedAt, | |
| 697 | + deletedAt: row.deletedAt, | |
| 698 | + messages: messagesByThread.get(row.threadId) ?? [], | |
| 699 | + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], | |
| 700 | + activities: activitiesByThread.get(row.threadId) ?? [], | |
| 701 | + checkpoints: checkpointsByThread.get(row.threadId) ?? [], | |
| 702 | + session: sessionsByThread.get(row.threadId) ?? null, | |
| 703 | + })); | |
| 704 | + | |
| 705 | + const snapshot = { | |
| 706 | + snapshotSequence: computeSnapshotSequence(stateRows), | |
| 707 | + projects, | |
| 708 | + threads, | |
| 709 | + updatedAt: updatedAt ?? new Date(0).toISOString(), | |
| 710 | + }; | |
| 711 | + | |
| 712 | + return yield* decodeReadModel(snapshot).pipe( | |
| 713 | + Effect.mapError( | |
| 714 | + toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), | |
| 715 | + ), | |
| 716 | + ); | |
| 717 | + }), | |
| 718 | + ), | |
| 702 | 719 | Effect.mapError((error) => { |
| 703 | 720 | if (isPersistenceError(error)) { |
| 704 | 721 | return error; |
| @@ -732,19 +749,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 27 unmodified lines | ||
| 732 | 749 | "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", |
| 733 | 750 | ), |
| 734 | 751 | ), |
| 735 | − Effect.map( | |
| 736 | − Option.map( | |
| 737 | − (row): OrchestrationProject => ({ | |
| 738 | − id: row.projectId, | |
| 739 | − title: row.title, | |
| 740 | − workspaceRoot: row.workspaceRoot, | |
| 741 | − defaultModelSelection: row.defaultModelSelection, | |
| 742 | − scripts: row.scripts, | |
| 743 | − createdAt: row.createdAt, | |
| 744 | − updatedAt: row.updatedAt, | |
| 745 | − deletedAt: row.deletedAt, | |
| 746 | − }), | |
| 747 | − ), | |
| 752 | + Effect.flatMap((option) => | |
| 753 | + Option.isNone(option) | |
| 754 | + ? Effect.succeed(Option.none<OrchestrationProject>()) | |
| 755 | + : repositoryIdentityResolver.resolve(option.value.workspaceRoot).pipe( | |
| 756 | + Effect.map((repositoryIdentity) => | |
| 757 | + Option.some({ | |
| 758 | + id: option.value.projectId, | |
| 759 | + title: option.value.title, | |
| 760 | + workspaceRoot: option.value.workspaceRoot, | |
| 761 | + repositoryIdentity, | |
| 762 | + defaultModelSelection: option.value.defaultModelSelection, | |
| 763 | + scripts: option.value.scripts, | |
| 764 | + createdAt: option.value.createdAt, | |
| 765 | + updatedAt: option.value.updatedAt, | |
| 766 | + deletedAt: option.value.deletedAt, | |
| 767 | + } satisfies OrchestrationProject), | |
| 768 | + ), | |
| 769 | + ), | |
| 748 | 770 | ), |
| 749 | 771 | ); |
| 750 | 772 | |
| @@ -28,6 +28,7 @@ import { · 27 unmodified lines | ||
| 28 | 28 | } from "../../provider/Services/ProviderService.ts"; |
| 29 | 29 | import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; |
| 30 | 30 | import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; |
| 31 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 31 | 32 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 32 | 33 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 33 | 34 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| @@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => { · 184 unmodified lines | ||
| 218 | 219 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 219 | 220 | Layer.provide(OrchestrationEventStoreLive), |
| 220 | 221 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 222 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 221 | 223 | Layer.provide(SqlitePersistenceMemory), |
| 222 | 224 | ); |
| 223 | 225 | const layer = ProviderCommandReactorLive.pipe( |
| @@ -29,6 +29,7 @@ import { · 28 unmodified lines | ||
| 29 | 29 | ProviderService, |
| 30 | 30 | type ProviderServiceShape, |
| 31 | 31 | } from "../../provider/Services/ProviderService.ts"; |
| 32 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 32 | 33 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 33 | 34 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 34 | 35 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| @@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => { · 169 unmodified lines | ||
| 204 | 205 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 205 | 206 | Layer.provide(OrchestrationEventStoreLive), |
| 206 | 207 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 208 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 207 | 209 | Layer.provide(SqlitePersistenceMemory), |
| 208 | 210 | ); |
| 209 | 211 | const layer = ProviderRuntimeIngestionLive.pipe( |
| @@ -0,0 +1,193 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { expect, it } from "@effect/vitest"; | |
| 3 | +import { Duration, Effect, FileSystem, Layer } from "effect"; | |
| 4 | +import { TestClock } from "effect/testing"; | |
| 5 | + | |
| 6 | +import { runProcess } from "../../processRunner.ts"; | |
| 7 | +import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; | |
| 8 | +import { | |
| 9 | + makeRepositoryIdentityResolver, | |
| 10 | + RepositoryIdentityResolverLive, | |
| 11 | +} from "./RepositoryIdentityResolver.ts"; | |
| 12 | + | |
| 13 | +const git = (cwd: string, args: ReadonlyArray<string>) => | |
| 14 | + Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); | |
| 15 | + | |
| 16 | +const makeRepositoryIdentityResolverTestLayer = (options: { | |
| 17 | + readonly positiveCacheTtl?: Duration.Input; | |
| 18 | + readonly negativeCacheTtl?: Duration.Input; | |
| 19 | +}) => | |
| 20 | + Layer.effect( | |
| 21 | + RepositoryIdentityResolver, | |
| 22 | + makeRepositoryIdentityResolver({ | |
| 23 | + cacheCapacity: 16, | |
| 24 | + ...options, | |
| 25 | + }), | |
| 26 | + ); | |
| 27 | + | |
| 28 | +it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { | |
| 29 | + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => | |
| 30 | + Effect.gen(function* () { | |
| 31 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 32 | + const cwd = yield* fileSystem.makeTempDirectoryScoped({ | |
| 33 | + prefix: "t3-repository-identity-test-", | |
| 34 | + }); | |
| 35 | + | |
| 36 | + yield* git(cwd, ["init"]); | |
| 37 | + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | |
| 38 | + | |
| 39 | + const resolver = yield* RepositoryIdentityResolver; | |
| 40 | + const identity = yield* resolver.resolve(cwd); | |
| 41 | + | |
| 42 | + expect(identity).not.toBeNull(); | |
| 43 | + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | |
| 44 | + expect(identity?.displayName).toBe("t3tools/t3code"); | |
| 45 | + expect(identity?.provider).toBe("github"); | |
| 46 | + expect(identity?.owner).toBe("t3tools"); | |
| 47 | + expect(identity?.name).toBe("t3code"); | |
| 48 | + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | |
| 49 | + ); | |
| 50 | + | |
| 51 | + it.effect("returns null for non-git folders and repos without remotes", () => | |
| 52 | + Effect.gen(function* () { | |
| 53 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 54 | + const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 55 | + prefix: "t3-repository-identity-non-git-", | |
| 56 | + }); | |
| 57 | + const gitDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 58 | + prefix: "t3-repository-identity-no-remote-", | |
| 59 | + }); | |
| 60 | + | |
| 61 | + yield* git(gitDir, ["init"]); | |
| 62 | + | |
| 63 | + const resolver = yield* RepositoryIdentityResolver; | |
| 64 | + const nonGitIdentity = yield* resolver.resolve(nonGitDir); | |
| 65 | + const noRemoteIdentity = yield* resolver.resolve(gitDir); | |
| 66 | + | |
| 67 | + expect(nonGitIdentity).toBeNull(); | |
| 68 | + expect(noRemoteIdentity).toBeNull(); | |
| 69 | + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | |
| 70 | + ); | |
| 71 | + | |
| 72 | + it.effect("prefers upstream over origin when both remotes are configured", () => | |
| 73 | + Effect.gen(function* () { | |
| 74 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 75 | + const cwd = yield* fileSystem.makeTempDirectoryScoped({ | |
| 76 | + prefix: "t3-repository-identity-upstream-test-", | |
| 77 | + }); | |
| 78 | + | |
| 79 | + yield* git(cwd, ["init"]); | |
| 80 | + yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); | |
| 81 | + yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); | |
| 82 | + | |
| 83 | + const resolver = yield* RepositoryIdentityResolver; | |
| 84 | + const identity = yield* resolver.resolve(cwd); | |
| 85 | + | |
| 86 | + expect(identity).not.toBeNull(); | |
| 87 | + expect(identity?.locator.remoteName).toBe("upstream"); | |
| 88 | + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | |
| 89 | + expect(identity?.displayName).toBe("t3tools/t3code"); | |
| 90 | + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | |
| 91 | + ); | |
| 92 | + | |
| 93 | + it.effect("uses the last remote path segment as the repository name for nested groups", () => | |
| 94 | + Effect.gen(function* () { | |
| 95 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 96 | + const cwd = yield* fileSystem.makeTempDirectoryScoped({ | |
| 97 | + prefix: "t3-repository-identity-nested-group-test-", | |
| 98 | + }); | |
| 99 | + | |
| 100 | + yield* git(cwd, ["init"]); | |
| 101 | + yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); | |
| 102 | + | |
| 103 | + const resolver = yield* RepositoryIdentityResolver; | |
| 104 | + const identity = yield* resolver.resolve(cwd); | |
| 105 | + | |
| 106 | + expect(identity).not.toBeNull(); | |
| 107 | + expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code"); | |
| 108 | + expect(identity?.displayName).toBe("t3tools/platform/t3code"); | |
| 109 | + expect(identity?.owner).toBe("t3tools"); | |
| 110 | + expect(identity?.name).toBe("t3code"); | |
| 111 | + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | |
| 112 | + ); | |
| 113 | + | |
| 114 | + it.effect( | |
| 115 | + "refreshes cached null identities after the negative TTL when a remote is configured later", | |
| 116 | + () => | |
| 117 | + Effect.gen(function* () { | |
| 118 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 119 | + const cwd = yield* fileSystem.makeTempDirectoryScoped({ | |
| 120 | + prefix: "t3-repository-identity-late-remote-test-", | |
| 121 | + }); | |
| 122 | + | |
| 123 | + yield* git(cwd, ["init"]); | |
| 124 | + | |
| 125 | + const resolver = yield* RepositoryIdentityResolver; | |
| 126 | + const initialIdentity = yield* resolver.resolve(cwd); | |
| 127 | + expect(initialIdentity).toBeNull(); | |
| 128 | + | |
| 129 | + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | |
| 130 | + | |
| 131 | + const cachedIdentity = yield* resolver.resolve(cwd); | |
| 132 | + expect(cachedIdentity).toBeNull(); | |
| 133 | + | |
| 134 | + yield* TestClock.adjust(Duration.millis(120)); | |
| 135 | + | |
| 136 | + const refreshedIdentity = yield* resolver.resolve(cwd); | |
| 137 | + expect(refreshedIdentity).not.toBeNull(); | |
| 138 | + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | |
| 139 | + expect(refreshedIdentity?.name).toBe("t3code"); | |
| 140 | + }).pipe( | |
| 141 | + Effect.provide( | |
| 142 | + Layer.merge( | |
| 143 | + TestClock.layer(), | |
| 144 | + makeRepositoryIdentityResolverTestLayer({ | |
| 145 | + negativeCacheTtl: Duration.millis(50), | |
| 146 | + positiveCacheTtl: Duration.seconds(1), | |
| 147 | + }), | |
| 148 | + ), | |
| 149 | + ), | |
| 150 | + ), | |
| 151 | + ); | |
| 152 | + | |
| 153 | + it.effect("refreshes cached identities after the positive TTL when a remote changes", () => | |
| 154 | + Effect.gen(function* () { | |
| 155 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 156 | + const cwd = yield* fileSystem.makeTempDirectoryScoped({ | |
| 157 | + prefix: "t3-repository-identity-remote-change-test-", | |
| 158 | + }); | |
| 159 | + | |
| 160 | + yield* git(cwd, ["init"]); | |
| 161 | + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | |
| 162 | + | |
| 163 | + const resolver = yield* RepositoryIdentityResolver; | |
| 164 | + const initialIdentity = yield* resolver.resolve(cwd); | |
| 165 | + expect(initialIdentity).not.toBeNull(); | |
| 166 | + expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | |
| 167 | + | |
| 168 | + yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); | |
| 169 | + | |
| 170 | + const cachedIdentity = yield* resolver.resolve(cwd); | |
| 171 | + expect(cachedIdentity).not.toBeNull(); | |
| 172 | + expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | |
| 173 | + | |
| 174 | + yield* TestClock.adjust(Duration.millis(180)); | |
| 175 | + | |
| 176 | + const refreshedIdentity = yield* resolver.resolve(cwd); | |
| 177 | + expect(refreshedIdentity).not.toBeNull(); | |
| 178 | + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); | |
| 179 | + expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next"); | |
| 180 | + expect(refreshedIdentity?.name).toBe("t3code-next"); | |
| 181 | + }).pipe( | |
| 182 | + Effect.provide( | |
| 183 | + Layer.merge( | |
| 184 | + TestClock.layer(), | |
| 185 | + makeRepositoryIdentityResolverTestLayer({ | |
| 186 | + negativeCacheTtl: Duration.millis(50), | |
| 187 | + positiveCacheTtl: Duration.millis(100), | |
| 188 | + }), | |
| 189 | + ), | |
| 190 | + ), | |
| 191 | + ), | |
| 192 | + ); | |
| 193 | +}); | |
| @@ -0,0 +1,147 @@ | ||
| 1 | +import type { RepositoryIdentity } from "@t3tools/contracts"; | |
| 2 | +import { Cache, Duration, Effect, Exit, Layer } from "effect"; | |
| 3 | +import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 4 | + | |
| 5 | +import { runProcess } from "../../processRunner.ts"; | |
| 6 | +import { | |
| 7 | + RepositoryIdentityResolver, | |
| 8 | + type RepositoryIdentityResolverShape, | |
| 9 | +} from "../Services/RepositoryIdentityResolver.ts"; | |
| 10 | + | |
| 11 | +function parseRemoteFetchUrls(stdout: string): Map<string, string> { | |
| 12 | + const remotes = new Map<string, string>(); | |
| 13 | + for (const line of stdout.split("\n")) { | |
| 14 | + const trimmed = line.trim(); | |
| 15 | + if (trimmed.length === 0) continue; | |
| 16 | + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); | |
| 17 | + if (!match) continue; | |
| 18 | + const [, remoteName = "", remoteUrl = "", direction = ""] = match; | |
| 19 | + if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { | |
| 20 | + continue; | |
| 21 | + } | |
| 22 | + remotes.set(remoteName, remoteUrl); | |
| 23 | + } | |
| 24 | + return remotes; | |
| 25 | +} | |
| 26 | + | |
| 27 | +function pickPrimaryRemote( | |
| 28 | + remotes: ReadonlyMap<string, string>, | |
| 29 | +): { readonly remoteName: string; readonly remoteUrl: string } | null { | |
| 30 | + for (const preferredRemoteName of ["upstream", "origin"] as const) { | |
| 31 | + const remoteUrl = remotes.get(preferredRemoteName); | |
| 32 | + if (remoteUrl) { | |
| 33 | + return { remoteName: preferredRemoteName, remoteUrl }; | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + const [remoteName, remoteUrl] = | |
| 38 | + [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? []; | |
| 39 | + return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; | |
| 40 | +} | |
| 41 | + | |
| 42 | +function buildRepositoryIdentity(input: { | |
| 43 | + readonly remoteName: string; | |
| 44 | + readonly remoteUrl: string; | |
| 45 | +}): RepositoryIdentity { | |
| 46 | + const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); | |
| 47 | + const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); | |
| 48 | + const repositoryPath = canonicalKey.split("/").slice(1).join("/"); | |
| 49 | + const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); | |
| 50 | + const [owner] = repositoryPathSegments; | |
| 51 | + const repositoryName = repositoryPathSegments.at(-1); | |
| 52 | + | |
| 53 | + return { | |
| 54 | + canonicalKey, | |
| 55 | + locator: { | |
| 56 | + source: "git-remote", | |
| 57 | + remoteName: input.remoteName, | |
| 58 | + remoteUrl: input.remoteUrl, | |
| 59 | + }, | |
| 60 | + ...(repositoryPath ? { displayName: repositoryPath } : {}), | |
| 61 | + ...(hostingProvider ? { provider: hostingProvider.kind } : {}), | |
| 62 | + ...(owner ? { owner } : {}), | |
| 63 | + ...(repositoryName ? { name: repositoryName } : {}), | |
| 64 | + }; | |
| 65 | +} | |
| 66 | + | |
| 67 | +const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; | |
| 68 | +const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); | |
| 69 | +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10); | |
| 70 | + | |
| 71 | +interface RepositoryIdentityResolverOptions { | |
| 72 | + readonly cacheCapacity?: number; | |
| 73 | + readonly positiveCacheTtl?: Duration.Input; | |
| 74 | + readonly negativeCacheTtl?: Duration.Input; | |
| 75 | +} | |
| 76 | + | |
| 77 | +async function resolveRepositoryIdentityCacheKey(cwd: string): Promise<string> { | |
| 78 | + let cacheKey = cwd; | |
| 79 | + | |
| 80 | + try { | |
| 81 | + const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { | |
| 82 | + allowNonZeroExit: true, | |
| 83 | + }); | |
| 84 | + if (topLevelResult.code !== 0) { | |
| 85 | + return cacheKey; | |
| 86 | + } | |
| 87 | + | |
| 88 | + const candidate = topLevelResult.stdout.trim(); | |
| 89 | + if (candidate.length > 0) { | |
| 90 | + cacheKey = candidate; | |
| 91 | + } | |
| 92 | + } catch { | |
| 93 | + return cacheKey; | |
| 94 | + } | |
| 95 | + | |
| 96 | + return cacheKey; | |
| 97 | +} | |
| 98 | + | |
| 99 | +async function resolveRepositoryIdentityFromCacheKey( | |
| 100 | + cacheKey: string, | |
| 101 | +): Promise<RepositoryIdentity | null> { | |
| 102 | + try { | |
| 103 | + const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], { | |
| 104 | + allowNonZeroExit: true, | |
| 105 | + }); | |
| 106 | + if (remoteResult.code !== 0) { | |
| 107 | + return null; | |
| 108 | + } | |
| 109 | + | |
| 110 | + const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); | |
| 111 | + return remote ? buildRepositoryIdentity(remote) : null; | |
| 112 | + } catch { | |
| 113 | + return null; | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( | |
| 118 | + function* (options: RepositoryIdentityResolverOptions = {}) { | |
| 119 | + const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>({ | |
| 120 | + capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, | |
| 121 | + lookup: (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)), | |
| 122 | + timeToLive: Exit.match({ | |
| 123 | + onSuccess: (value) => | |
| 124 | + value === null | |
| 125 | + ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) | |
| 126 | + : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), | |
| 127 | + onFailure: () => Duration.zero, | |
| 128 | + }), | |
| 129 | + }); | |
| 130 | + | |
| 131 | + const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( | |
| 132 | + "RepositoryIdentityResolver.resolve", | |
| 133 | + )(function* (cwd) { | |
| 134 | + const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd)); | |
| 135 | + return yield* Cache.get(repositoryIdentityCache, cacheKey); | |
| 136 | + }); | |
| 137 | + | |
| 138 | + return { | |
| 139 | + resolve, | |
| 140 | + } satisfies RepositoryIdentityResolverShape; | |
| 141 | + }, | |
| 142 | +); | |
| 143 | + | |
| 144 | +export const RepositoryIdentityResolverLive = Layer.effect( | |
| 145 | + RepositoryIdentityResolver, | |
| 146 | + makeRepositoryIdentityResolver(), | |
| 147 | +); | |
| @@ -0,0 +1,12 @@ | ||
| 1 | +import type { RepositoryIdentity } from "@t3tools/contracts"; | |
| 2 | +import { ServiceMap } from "effect"; | |
| 3 | +import type { Effect } from "effect"; | |
| 4 | + | |
| 5 | +export interface RepositoryIdentityResolverShape { | |
| 6 | + readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>; | |
| 7 | +} | |
| 8 | + | |
| 9 | +export class RepositoryIdentityResolver extends ServiceMap.Service< | |
| 10 | + RepositoryIdentityResolver, | |
| 11 | + RepositoryIdentityResolverShape | |
| 12 | +>()("t3/project/Services/RepositoryIdentityResolver") {} | |
| @@ -5,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | CommandId, |
| 7 | 7 | DEFAULT_SERVER_SETTINGS, |
| 8 | + EnvironmentId, | |
| 9 | + EventId, | |
| 8 | 10 | GitCommandError, |
| 9 | 11 | KeybindingRule, |
| 10 | 12 | MessageId, |
| @@ -83,6 +85,14 @@ import { · 72 unmodified lines | ||
| 83 | 85 | ProjectSetupScriptRunner, |
| 84 | 86 | type ProjectSetupScriptRunnerShape, |
| 85 | 87 | } from "./project/Services/ProjectSetupScriptRunner.ts"; |
| 88 | +import { | |
| 89 | + RepositoryIdentityResolver, | |
| 90 | + type RepositoryIdentityResolverShape, | |
| 91 | +} from "./project/Services/RepositoryIdentityResolver.ts"; | |
| 92 | +import { | |
| 93 | + ServerEnvironment, | |
| 94 | + type ServerEnvironmentShape, | |
| 95 | +} from "./environment/Services/ServerEnvironment.ts"; | |
| 86 | 96 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; |
| 87 | 97 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 88 | 98 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| @@ -93,6 +103,18 @@ const defaultModelSelection = { · 4 unmodified lines | ||
| 93 | 103 | provider: "codex", |
| 94 | 104 | model: "gpt-5-codex", |
| 95 | 105 | } as const; |
| 106 | +const testEnvironmentDescriptor = { | |
| 107 | + environmentId: EnvironmentId.makeUnsafe("environment-test"), | |
| 108 | + label: "Test environment", | |
| 109 | + platform: { | |
| 110 | + os: "darwin" as const, | |
| 111 | + arch: "arm64" as const, | |
| 112 | + }, | |
| 113 | + serverVersion: "0.0.0-test", | |
| 114 | + capabilities: { | |
| 115 | + repositoryIdentity: true, | |
| 116 | + }, | |
| 117 | +}; | |
| 96 | 118 | |
| 97 | 119 | const makeDefaultOrchestrationReadModel = () => { |
| 98 | 120 | const now = new Date().toISOString(); |
| @@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: { · 171 unmodified lines | ||
| 270 | 292 | browserTraceCollector?: Partial<BrowserTraceCollectorShape>; |
| 271 | 293 | serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>; |
| 272 | 294 | serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>; |
| 295 | + serverEnvironment?: Partial<ServerEnvironmentShape>; | |
| 296 | + repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>; | |
| 273 | 297 | }; |
| 274 | 298 | }) => |
| 275 | 299 | Effect.gen(function* () { |
| @@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: { · 140 unmodified lines | ||
| 416 | 440 | ...options?.layers?.serverRuntimeStartup, |
| 417 | 441 | }), |
| 418 | 442 | ), |
| 443 | + Layer.provide( | |
| 444 | + Layer.mock(ServerEnvironment)({ | |
| 445 | + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), | |
| 446 | + getDescriptor: Effect.succeed(testEnvironmentDescriptor), | |
| 447 | + ...options?.layers?.serverEnvironment, | |
| 448 | + }), | |
| 449 | + ), | |
| 450 | + Layer.provide( | |
| 451 | + Layer.mock(RepositoryIdentityResolver)({ | |
| 452 | + resolve: () => Effect.succeed(null), | |
| 453 | + ...options?.layers?.repositoryIdentityResolver, | |
| 454 | + }), | |
| 455 | + ), | |
| 419 | 456 | Layer.provide(workspaceAndProjectServicesLayer), |
| 420 | 457 | Layer.provideMerge(FetchHttpClient.layer), |
| 421 | 458 | Layer.provide(layerConfig), |
| @@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 647 unmodified lines | ||
| 1069 | 1106 | sequence: 1, |
| 1070 | 1107 | type: "welcome" as const, |
| 1071 | 1108 | payload: { |
| 1109 | + environment: testEnvironmentDescriptor, | |
| 1072 | 1110 | cwd: "/tmp/project", |
| 1073 | 1111 | projectName: "project", |
| 1074 | 1112 | }, |
| @@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 3 unmodified lines | ||
| 1078 | 1116 | version: 1 as const, |
| 1079 | 1117 | sequence: 2, |
| 1080 | 1118 | type: "ready" as const, |
| 1081 | − payload: { at: new Date().toISOString() }, | |
| 1119 | + payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor }, | |
| 1082 | 1120 | }); |
| 1083 | 1121 | |
| 1084 | 1122 | yield* buildAppUnderTest({ |
| @@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 911 unmodified lines | ||
| 1996 | 2034 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1997 | 2035 | ); |
| 1998 | 2036 | |
| 2037 | + it.effect("enriches replayed project events with repository identity metadata", () => | |
| 2038 | + Effect.gen(function* () { | |
| 2039 | + const repositoryIdentity = { | |
| 2040 | + canonicalKey: "github.com/t3tools/t3code", | |
| 2041 | + locator: { | |
| 2042 | + source: "git-remote" as const, | |
| 2043 | + remoteName: "origin", | |
| 2044 | + remoteUrl: "git@github.com:T3Tools/t3code.git", | |
| 2045 | + }, | |
| 2046 | + displayName: "T3Tools/t3code", | |
| 2047 | + provider: "github", | |
| 2048 | + owner: "T3Tools", | |
| 2049 | + name: "t3code", | |
| 2050 | + }; | |
| 2051 | + | |
| 2052 | + yield* buildAppUnderTest({ | |
| 2053 | + layers: { | |
| 2054 | + orchestrationEngine: { | |
| 2055 | + readEvents: (_fromSequenceExclusive) => | |
| 2056 | + Stream.make({ | |
| 2057 | + sequence: 1, | |
| 2058 | + eventId: EventId.makeUnsafe("event-1"), | |
| 2059 | + aggregateKind: "project", | |
| 2060 | + aggregateId: defaultProjectId, | |
| 2061 | + occurredAt: "2026-04-05T00:00:00.000Z", | |
| 2062 | + commandId: null, | |
| 2063 | + causationEventId: null, | |
| 2064 | + correlationId: null, | |
| 2065 | + metadata: {}, | |
| 2066 | + type: "project.created", | |
| 2067 | + payload: { | |
| 2068 | + projectId: defaultProjectId, | |
| 2069 | + title: "Default Project", | |
| 2070 | + workspaceRoot: "/tmp/default-project", | |
| 2071 | + defaultModelSelection, | |
| 2072 | + scripts: [], | |
| 2073 | + createdAt: "2026-04-05T00:00:00.000Z", | |
| 2074 | + updatedAt: "2026-04-05T00:00:00.000Z", | |
| 2075 | + }, | |
| 2076 | + } satisfies Extract<OrchestrationEvent, { type: "project.created" }>), | |
| 2077 | + }, | |
| 2078 | + repositoryIdentityResolver: { | |
| 2079 | + resolve: () => Effect.succeed(repositoryIdentity), | |
| 2080 | + }, | |
| 2081 | + }, | |
| 2082 | + }); | |
| 2083 | + | |
| 2084 | + const wsUrl = yield* getWsServerUrl("/ws"); | |
| 2085 | + const replayResult = yield* Effect.scoped( | |
| 2086 | + withWsRpcClient(wsUrl, (client) => | |
| 2087 | + client[ORCHESTRATION_WS_METHODS.replayEvents]({ | |
| 2088 | + fromSequenceExclusive: 0, | |
| 2089 | + }), | |
| 2090 | + ), | |
| 2091 | + ); | |
| 2092 | + | |
| 2093 | + const replayedEvent = replayResult[0]; | |
| 2094 | + assert.equal(replayedEvent?.type, "project.created"); | |
| 2095 | + assert.deepEqual( | |
| 2096 | + replayedEvent && replayedEvent.type === "project.created" | |
| 2097 | + ? replayedEvent.payload.repositoryIdentity | |
| 2098 | + : null, | |
| 2099 | + repositoryIdentity, | |
| 2100 | + ); | |
| 2101 | + }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 2102 | + ); | |
| 2103 | + | |
| 1999 | 2104 | it.effect("closes thread terminals after a successful archive command", () => |
| 2000 | 2105 | Effect.gen(function* () { |
| 2001 | 2106 | const threadId = ThreadId.makeUnsafe("thread-archive"); |
| @@ -2498,6 +2603,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 496 unmodified lines | ||
| 2498 | 2603 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 2499 | 2604 | ); |
| 2500 | 2605 | |
| 2606 | + it.effect("enriches replayed project events only once before streaming them to subscribers", () => | |
| 2607 | + Effect.gen(function* () { | |
| 2608 | + let resolveCalls = 0; | |
| 2609 | + const repositoryIdentity = { | |
| 2610 | + canonicalKey: "github.com/t3tools/t3code", | |
| 2611 | + locator: { | |
| 2612 | + source: "git-remote" as const, | |
| 2613 | + remoteName: "origin", | |
| 2614 | + remoteUrl: "git@github.com:t3tools/t3code.git", | |
| 2615 | + }, | |
| 2616 | + displayName: "t3tools/t3code", | |
| 2617 | + provider: "github" as const, | |
| 2618 | + owner: "t3tools", | |
| 2619 | + name: "t3code", | |
| 2620 | + }; | |
| 2621 | + | |
| 2622 | + yield* buildAppUnderTest({ | |
| 2623 | + layers: { | |
| 2624 | + orchestrationEngine: { | |
| 2625 | + getReadModel: () => | |
| 2626 | + Effect.succeed({ | |
| 2627 | + ...makeDefaultOrchestrationReadModel(), | |
| 2628 | + snapshotSequence: 0, | |
| 2629 | + }), | |
| 2630 | + readEvents: () => | |
| 2631 | + Stream.make({ | |
| 2632 | + sequence: 1, | |
| 2633 | + eventId: EventId.makeUnsafe("event-1"), | |
| 2634 | + aggregateKind: "project", | |
| 2635 | + aggregateId: defaultProjectId, | |
| 2636 | + occurredAt: "2026-04-06T00:00:00.000Z", | |
| 2637 | + commandId: null, | |
| 2638 | + causationEventId: null, | |
| 2639 | + correlationId: null, | |
| 2640 | + metadata: {}, | |
| 2641 | + type: "project.meta-updated", | |
| 2642 | + payload: { | |
| 2643 | + projectId: defaultProjectId, | |
| 2644 | + title: "Replayed Project", | |
| 2645 | + updatedAt: "2026-04-06T00:00:00.000Z", | |
| 2646 | + }, | |
| 2647 | + } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>), | |
| 2648 | + streamDomainEvents: Stream.empty, | |
| 2649 | + }, | |
| 2650 | + repositoryIdentityResolver: { | |
| 2651 | + resolve: () => { | |
| 2652 | + resolveCalls += 1; | |
| 2653 | + return Effect.succeed(repositoryIdentity); | |
| 2654 | + }, | |
| 2655 | + }, | |
| 2656 | + }, | |
| 2657 | + }); | |
| 2658 | + | |
| 2659 | + const wsUrl = yield* getWsServerUrl("/ws"); | |
| 2660 | + const events = yield* Effect.scoped( | |
| 2661 | + withWsRpcClient(wsUrl, (client) => | |
| 2662 | + client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( | |
| 2663 | + Stream.take(1), | |
| 2664 | + Stream.runCollect, | |
| 2665 | + ), | |
| 2666 | + ), | |
| 2667 | + ); | |
| 2668 | + | |
| 2669 | + const event = Array.from(events)[0]; | |
| 2670 | + assert.equal(resolveCalls, 1); | |
| 2671 | + assert.equal(event?.type, "project.meta-updated"); | |
| 2672 | + assert.deepEqual( | |
| 2673 | + event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, | |
| 2674 | + repositoryIdentity, | |
| 2675 | + ); | |
| 2676 | + }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 2677 | + ); | |
| 2678 | + | |
| 2679 | + it.effect("enriches subscribed project meta updates with repository identity metadata", () => | |
| 2680 | + Effect.gen(function* () { | |
| 2681 | + const repositoryIdentity = { | |
| 2682 | + canonicalKey: "github.com/t3tools/t3code", | |
| 2683 | + locator: { | |
| 2684 | + source: "git-remote" as const, | |
| 2685 | + remoteName: "upstream", | |
| 2686 | + remoteUrl: "git@github.com:T3Tools/t3code.git", | |
| 2687 | + }, | |
| 2688 | + displayName: "T3Tools/t3code", | |
| 2689 | + provider: "github", | |
| 2690 | + owner: "T3Tools", | |
| 2691 | + name: "t3code", | |
| 2692 | + }; | |
| 2693 | + | |
| 2694 | + yield* buildAppUnderTest({ | |
| 2695 | + layers: { | |
| 2696 | + orchestrationEngine: { | |
| 2697 | + getReadModel: () => | |
| 2698 | + Effect.succeed({ | |
| 2699 | + ...makeDefaultOrchestrationReadModel(), | |
| 2700 | + snapshotSequence: 0, | |
| 2701 | + }), | |
| 2702 | + streamDomainEvents: Stream.make({ | |
| 2703 | + sequence: 1, | |
| 2704 | + eventId: EventId.makeUnsafe("event-1"), | |
| 2705 | + aggregateKind: "project", | |
| 2706 | + aggregateId: defaultProjectId, | |
| 2707 | + occurredAt: "2026-04-05T00:00:00.000Z", | |
| 2708 | + commandId: null, | |
| 2709 | + causationEventId: null, | |
| 2710 | + correlationId: null, | |
| 2711 | + metadata: {}, | |
| 2712 | + type: "project.meta-updated", | |
| 2713 | + payload: { | |
| 2714 | + projectId: defaultProjectId, | |
| 2715 | + title: "Renamed Project", | |
| 2716 | + updatedAt: "2026-04-05T00:00:00.000Z", | |
| 2717 | + }, | |
| 2718 | + } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>), | |
| 2719 | + }, | |
| 2720 | + repositoryIdentityResolver: { | |
| 2721 | + resolve: () => Effect.succeed(repositoryIdentity), | |
| 2722 | + }, | |
| 2723 | + }, | |
| 2724 | + }); | |
| 2725 | + | |
| 2726 | + const wsUrl = yield* getWsServerUrl("/ws"); | |
| 2727 | + const events = yield* Effect.scoped( | |
| 2728 | + withWsRpcClient(wsUrl, (client) => | |
| 2729 | + client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( | |
| 2730 | + Stream.take(1), | |
| 2731 | + Stream.runCollect, | |
| 2732 | + ), | |
| 2733 | + ), | |
| 2734 | + ); | |
| 2735 | + | |
| 2736 | + const event = Array.from(events)[0]; | |
| 2737 | + assert.equal(event?.type, "project.meta-updated"); | |
| 2738 | + assert.deepEqual( | |
| 2739 | + event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, | |
| 2740 | + repositoryIdentity, | |
| 2741 | + ); | |
| 2742 | + }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 2743 | + ); | |
| 2744 | + | |
| 2501 | 2745 | it.effect("routes websocket rpc orchestration.getSnapshot errors", () => |
| 2502 | 2746 | Effect.gen(function* () { |
| 2503 | 2747 | yield* buildAppUnderTest({ |
| @@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" · 43 unmodified lines | ||
| 44 | 44 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; |
| 45 | 45 | import { ServerSettingsLive } from "./serverSettings"; |
| 46 | 46 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; |
| 47 | +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver"; | |
| 47 | 48 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; |
| 48 | 49 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; |
| 49 | 50 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; |
| 50 | 51 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; |
| 51 | 52 | import { ObservabilityLive } from "./observability/Layers/Observability"; |
| 53 | +import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; | |
| 52 | 54 | |
| 53 | 55 | const PtyAdapterLive = Layer.unwrap( |
| 54 | 56 | Effect.gen(function* () { |
| @@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( · 144 unmodified lines | ||
| 199 | 201 | Layer.provideMerge(ServerSettingsLive), |
| 200 | 202 | Layer.provideMerge(WorkspaceLayerLive), |
| 201 | 203 | Layer.provideMerge(ProjectFaviconResolverLive), |
| 204 | + Layer.provideMerge(RepositoryIdentityResolverLive), | |
| 205 | + Layer.provideMerge(ServerEnvironmentLive), | |
| 202 | 206 | |
| 203 | 207 | // Misc. |
| 204 | 208 | Layer.provideMerge(AnalyticsServiceLayerLive), |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | 2 | import { assert, it } from "@effect/vitest"; |
| 2 | 3 | import { assertTrue } from "@effect/vitest/utils"; |
| 3 | 4 | import { Effect, Option } from "effect"; |
| @@ -9,12 +10,20 @@ it.effect( · 5 unmodified lines | ||
| 9 | 10 | () => |
| 10 | 11 | Effect.gen(function* () { |
| 11 | 12 | const lifecycleEvents = yield* ServerLifecycleEvents; |
| 13 | + const environment = { | |
| 14 | + environmentId: EnvironmentId.makeUnsafe("environment-test"), | |
| 15 | + label: "Test environment", | |
| 16 | + platform: { os: "darwin" as const, arch: "arm64" as const }, | |
| 17 | + serverVersion: "0.0.0-test", | |
| 18 | + capabilities: { repositoryIdentity: true }, | |
| 19 | + }; | |
| 12 | 20 | |
| 13 | 21 | const welcome = yield* lifecycleEvents |
| 14 | 22 | .publish({ |
| 15 | 23 | version: 1, |
| 16 | 24 | type: "welcome", |
| 17 | 25 | payload: { |
| 26 | + environment, | |
| 18 | 27 | cwd: "/tmp/project", |
| 19 | 28 | projectName: "project", |
| 20 | 29 | }, |
| @@ -29,6 +38,7 @@ it.effect( · 8 unmodified lines | ||
| 29 | 38 | type: "ready", |
| 30 | 39 | payload: { |
| 31 | 40 | at: new Date().toISOString(), |
| 41 | + environment, | |
| 32 | 42 | }, |
| 33 | 43 | }) |
| 34 | 44 | .pipe(Effect.timeoutOption("50 millis")); |
| @@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap · 26 unmodified lines | ||
| 27 | 27 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| 28 | 28 | import { ServerLifecycleEvents } from "./serverLifecycleEvents"; |
| 29 | 29 | import { ServerSettingsService } from "./serverSettings"; |
| 30 | +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; | |
| 30 | 31 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 31 | 32 | |
| 32 | 33 | const isWildcardHost = (host: string | undefined): boolean => |
| @@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 229 unmodified lines | ||
| 262 | 263 | const orchestrationReactor = yield* OrchestrationReactor; |
| 263 | 264 | const lifecycleEvents = yield* ServerLifecycleEvents; |
| 264 | 265 | const serverSettings = yield* ServerSettingsService; |
| 266 | + const serverEnvironment = yield* ServerEnvironment; | |
| 265 | 267 | |
| 266 | 268 | const commandGate = yield* makeCommandGate; |
| 267 | 269 | const httpListening = yield* Deferred.make<void>(); |
| @@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 40 unmodified lines | ||
| 308 | 310 | |
| 309 | 311 | yield* Effect.logDebug("startup phase: preparing welcome payload"); |
| 310 | 312 | const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); |
| 313 | + const environment = yield* serverEnvironment.getDescriptor; | |
| 311 | 314 | yield* Effect.logDebug("startup phase: publishing welcome event", { |
| 315 | + environmentId: environment.environmentId, | |
| 312 | 316 | cwd: welcome.cwd, |
| 313 | 317 | projectName: welcome.projectName, |
| 314 | 318 | bootstrapProjectId: welcome.bootstrapProjectId, |
| @@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 4 unmodified lines | ||
| 319 | 323 | lifecycleEvents.publish({ |
| 320 | 324 | version: 1, |
| 321 | 325 | type: "welcome", |
| 322 | − payload: welcome, | |
| 326 | + payload: { | |
| 327 | + environment, | |
| 328 | + ...welcome, | |
| 329 | + }, | |
| 323 | 330 | }), |
| 324 | 331 | ); |
| 325 | 332 | }).pipe( |
| @@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 28 unmodified lines | ||
| 354 | 361 | lifecycleEvents.publish({ |
| 355 | 362 | version: 1, |
| 356 | 363 | type: "ready", |
| 357 | − payload: { at: new Date().toISOString() }, | |
| 364 | + payload: { | |
| 365 | + at: new Date().toISOString(), | |
| 366 | + environment: yield* serverEnvironment.getDescriptor, | |
| 367 | + }, | |
| 358 | 368 | }), |
| 359 | 369 | ); |
| 360 | 370 | |
| @@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; · 46 unmodified lines | ||
| 47 | 47 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; |
| 48 | 48 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; |
| 49 | 49 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; |
| 50 | +import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; | |
| 51 | +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; | |
| 50 | 52 | |
| 51 | 53 | const WsRpcLayer = WsRpcGroup.toLayer( |
| 52 | 54 | Effect.gen(function* () { |
| @@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 14 unmodified lines | ||
| 67 | 69 | const workspaceEntries = yield* WorkspaceEntries; |
| 68 | 70 | const workspaceFileSystem = yield* WorkspaceFileSystem; |
| 69 | 71 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; |
| 72 | + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | |
| 73 | + const serverEnvironment = yield* ServerEnvironment; | |
| 70 | 74 | const serverCommandId = (tag: string) => |
| 71 | 75 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); |
| 72 | 76 | |
| @@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 40 unmodified lines | ||
| 113 | 117 | }); |
| 114 | 118 | }; |
| 115 | 119 | |
| 120 | + const enrichProjectEvent = ( | |
| 121 | + event: OrchestrationEvent, | |
| 122 | + ): Effect.Effect<OrchestrationEvent, never, never> => { | |
| 123 | + switch (event.type) { | |
| 124 | + case "project.created": | |
| 125 | + return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( | |
| 126 | + Effect.map((repositoryIdentity) => ({ | |
| 127 | + ...event, | |
| 128 | + payload: { | |
| 129 | + ...event.payload, | |
| 130 | + repositoryIdentity, | |
| 131 | + }, | |
| 132 | + })), | |
| 133 | + ); | |
| 134 | + case "project.meta-updated": | |
| 135 | + return Effect.gen(function* () { | |
| 136 | + const workspaceRoot = | |
| 137 | + event.payload.workspaceRoot ?? | |
| 138 | + (yield* orchestrationEngine.getReadModel()).projects.find( | |
| 139 | + (project) => project.id === event.payload.projectId, | |
| 140 | + )?.workspaceRoot ?? | |
| 141 | + null; | |
| 142 | + if (workspaceRoot === null) { | |
| 143 | + return event; | |
| 144 | + } | |
| 145 | + | |
| 146 | + const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); | |
| 147 | + return { | |
| 148 | + ...event, | |
| 149 | + payload: { | |
| 150 | + ...event.payload, | |
| 151 | + repositoryIdentity, | |
| 152 | + }, | |
| 153 | + } satisfies OrchestrationEvent; | |
| 154 | + }); | |
| 155 | + default: | |
| 156 | + return Effect.succeed(event); | |
| 157 | + } | |
| 158 | + }; | |
| 159 | + | |
| 160 | + const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) => | |
| 161 | + Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); | |
| 162 | + | |
| 116 | 163 | const dispatchBootstrapTurnStart = ( |
| 117 | 164 | command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>, |
| 118 | 165 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => |
| @@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 210 unmodified lines | ||
| 329 | 376 | const keybindingsConfig = yield* keybindings.loadConfigState; |
| 330 | 377 | const providers = yield* providerRegistry.getProviders; |
| 331 | 378 | const settings = yield* serverSettings.getSettings; |
| 379 | + const environment = yield* serverEnvironment.getDescriptor; | |
| 332 | 380 | |
| 333 | 381 | return { |
| 382 | + environment, | |
| 334 | 383 | cwd: config.cwd, |
| 335 | 384 | keybindingsConfigPath: config.keybindingsConfigPath, |
| 336 | 385 | keybindings: keybindingsConfig.keybindings, |
| @@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 98 unmodified lines | ||
| 435 | 484 | ), |
| 436 | 485 | ).pipe( |
| 437 | 486 | Effect.map((events) => Array.from(events)), |
| 487 | + Effect.flatMap(enrichOrchestrationEvents), | |
| 438 | 488 | Effect.mapError( |
| 439 | 489 | (cause) => |
| 440 | 490 | new OrchestrationReplayEventsError({ |
| @@ -455,10 +505,14 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 14 unmodified lines | ||
| 455 | 505 | orchestrationEngine.readEvents(fromSequenceExclusive), |
| 456 | 506 | ).pipe( |
| 457 | 507 | Effect.map((events) => Array.from(events)), |
| 508 | + Effect.flatMap(enrichOrchestrationEvents), | |
| 458 | 509 | Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)), |
| 459 | 510 | ); |
| 460 | 511 | const replayStream = Stream.fromIterable(replayEvents); |
| 461 | − const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents); | |
| 512 | + const liveStream = orchestrationEngine.streamDomainEvents.pipe( | |
| 513 | + Stream.mapEffect(enrichProjectEvent), | |
| 514 | + ); | |
| 515 | + const source = Stream.merge(replayStream, liveStream); | |
| 462 | 516 | type SequenceState = { |
| 463 | 517 | readonly nextSequence: number; |
| 464 | 518 | readonly pendingBySequence: Map<number, OrchestrationEvent>; |
| @@ -12,6 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | rel="stylesheet" |
| 13 | 13 | /> |
| 14 | 14 | <title>T3 Code (Alpha)</title> |
| 15 | + <script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script> | |
| 15 | 16 | </head> |
| 16 | 17 | <body> |
| 17 | 18 | <div id="root"></div> |
| @@ -23,6 +23,7 @@ · 22 unmodified lines | ||
| 23 | 23 | "@formkit/auto-animate": "^0.9.0", |
| 24 | 24 | "@lexical/react": "^0.41.0", |
| 25 | 25 | "@pierre/diffs": "^1.1.0-beta.16", |
| 26 | + "@t3tools/client-runtime": "workspace:*", | |
| 26 | 27 | "@t3tools/contracts": "workspace:*", |
| 27 | 28 | "@t3tools/shared": "workspace:*", |
| 28 | 29 | "@tanstack/react-pacer": "^0.19.4", |
| @@ -1,11 +1,13 @@ | ||
| 1 | −import type { ThreadId } from "@t3tools/contracts"; | |
| 1 | +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | |
| 2 | +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | 3 | import { FolderIcon, GitForkIcon } from "lucide-react"; |
| 3 | −import { useCallback } from "react"; | |
| 4 | +import { useCallback, useMemo } from "react"; | |
| 4 | 5 | |
| 6 | +import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; | |
| 7 | +import { readEnvironmentApi } from "../environmentApi"; | |
| 5 | 8 | import { newCommandId } from "../lib/utils"; |
| 6 | −import { readNativeApi } from "../nativeApi"; | |
| 7 | −import { useComposerDraftStore } from "../composerDraftStore"; | |
| 8 | 9 | import { useStore } from "../store"; |
| 10 | +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | |
| 9 | 11 | import { |
| 10 | 12 | EnvMode, |
| 11 | 13 | resolveDraftEnvModeAfterBranchChange, |
| @@ -20,7 +22,9 @@ const envModeItems = [ · 8 unmodified lines | ||
| 20 | 22 | ] as const; |
| 21 | 23 | |
| 22 | 24 | interface BranchToolbarProps { |
| 25 | + environmentId: EnvironmentId; | |
| 23 | 26 | threadId: ThreadId; |
| 27 | + draftId?: DraftId; | |
| 24 | 28 | onEnvModeChange: (mode: EnvMode) => void; |
| 25 | 29 | envLocked: boolean; |
| 26 | 30 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| @@ -28,21 +32,34 @@ interface BranchToolbarProps { · 1 unmodified line | ||
| 28 | 32 | } |
| 29 | 33 | |
| 30 | 34 | export default function BranchToolbar({ |
| 35 | + environmentId, | |
| 31 | 36 | threadId, |
| 37 | + draftId, | |
| 32 | 38 | onEnvModeChange, |
| 33 | 39 | envLocked, |
| 34 | 40 | onCheckoutPullRequestRequest, |
| 35 | 41 | onComposerFocusRequest, |
| 36 | 42 | }: BranchToolbarProps) { |
| 37 | − const serverThread = useStore((store) => store.threadShellById[threadId]); | |
| 38 | − const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null); | |
| 43 | + const threadRef = useMemo( | |
| 44 | + () => scopeThreadRef(environmentId, threadId), | |
| 45 | + [environmentId, threadId], | |
| 46 | + ); | |
| 47 | + const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); | |
| 48 | + const serverThread = useStore(serverThreadSelector); | |
| 49 | + const serverSession = serverThread?.session ?? null; | |
| 39 | 50 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); |
| 40 | − const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); | |
| 51 | + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); | |
| 41 | 52 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 42 | − const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; | |
| 43 | − const activeProject = useStore((store) => | |
| 44 | − activeProjectId ? store.projectById[activeProjectId] : undefined, | |
| 53 | + const activeProjectRef = serverThread | |
| 54 | + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) | |
| 55 | + : draftThread | |
| 56 | + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | |
| 57 | + : null; | |
| 58 | + const activeProjectSelector = useMemo( | |
| 59 | + () => createProjectSelectorByRef(activeProjectRef), | |
| 60 | + [activeProjectRef], | |
| 45 | 61 | ); |
| 62 | + const activeProject = useStore(activeProjectSelector); | |
| 46 | 63 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); |
| 47 | 64 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; |
| 48 | 65 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| @@ -56,8 +73,8 @@ export default function BranchToolbar({ · 7 unmodified lines | ||
| 56 | 73 | |
| 57 | 74 | const setThreadBranch = useCallback( |
| 58 | 75 | (branch: string | null, worktreePath: string | null) => { |
| 59 | − if (!activeThreadId) return; | |
| 60 | − const api = readNativeApi(); | |
| 76 | + if (!activeThreadId || !activeProject) return; | |
| 77 | + const api = readEnvironmentApi(environmentId); | |
| 61 | 78 | // If the effective cwd is about to change, stop the running session so the |
| 62 | 79 | // next message creates a new one with the correct cwd. |
| 63 | 80 | if (serverSession && worktreePath !== activeWorktreePath && api) { |
| @@ -88,20 +105,24 @@ export default function BranchToolbar({ · 24 unmodified lines | ||
| 88 | 105 | currentWorktreePath: activeWorktreePath, |
| 89 | 106 | effectiveEnvMode, |
| 90 | 107 | }); |
| 91 | − setDraftThreadContext(threadId, { | |
| 108 | + setDraftThreadContext(draftId ?? threadRef, { | |
| 92 | 109 | branch, |
| 93 | 110 | worktreePath, |
| 94 | 111 | envMode: nextDraftEnvMode, |
| 112 | + projectRef: scopeProjectRef(environmentId, activeProject.id), | |
| 95 | 113 | }); |
| 96 | 114 | }, |
| 97 | 115 | [ |
| 98 | 116 | activeThreadId, |
| 117 | + activeProject, | |
| 99 | 118 | serverSession, |
| 100 | 119 | activeWorktreePath, |
| 101 | 120 | hasServerThread, |
| 102 | 121 | setThreadBranchAction, |
| 103 | 122 | setDraftThreadContext, |
| 104 | − threadId, | |
| 123 | + draftId, | |
| 124 | + threadRef, | |
| 125 | + environmentId, | |
| 105 | 126 | effectiveEnvMode, |
| 106 | 127 | ], |
| 107 | 128 | ); |
| @@ -156,6 +177,7 @@ export default function BranchToolbar({ · 48 unmodified lines | ||
| 156 | 177 | )} |
| 157 | 178 | |
| 158 | 179 | <BranchToolbarBranchSelector |
| 180 | + environmentId={environmentId} | |
| 159 | 181 | activeProjectCwd={activeProject.cwd} |
| 160 | 182 | activeThreadBranch={activeThreadBranch} |
| 161 | 183 | activeWorktreePath={activeWorktreePath} |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import type { GitBranch } from "@t3tools/contracts"; | |
| 1 | +import type { EnvironmentId, GitBranch } from "@t3tools/contracts"; | |
| 2 | 2 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | 4 | import { ChevronDownIcon } from "lucide-react"; |
| @@ -16,7 +16,7 @@ import { · 11 unmodified lines | ||
| 16 | 16 | |
| 17 | 17 | import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; |
| 18 | 18 | import { useGitStatus } from "../lib/gitStatusState"; |
| 19 | −import { readNativeApi } from "../nativeApi"; | |
| 19 | +import { readEnvironmentApi } from "../environmentApi"; | |
| 20 | 20 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 21 | 21 | import { |
| 22 | 22 | deriveLocalBranchNameFromRemoteRef, |
| @@ -39,6 +39,7 @@ import { · 16 unmodified lines | ||
| 39 | 39 | import { toastManager } from "./ui/toast"; |
| 40 | 40 | |
| 41 | 41 | interface BranchToolbarBranchSelectorProps { |
| 42 | + environmentId: EnvironmentId; | |
| 42 | 43 | activeProjectCwd: string; |
| 43 | 44 | activeThreadBranch: string | null; |
| 44 | 45 | activeWorktreePath: string | null; |
| @@ -70,6 +71,7 @@ function getBranchTriggerLabel(input: { · 25 unmodified lines | ||
| 70 | 71 | } |
| 71 | 72 | |
| 72 | 73 | export function BranchToolbarBranchSelector({ |
| 74 | + environmentId, | |
| 73 | 75 | activeProjectCwd, |
| 74 | 76 | activeThreadBranch, |
| 75 | 77 | activeWorktreePath, |
| @@ -85,16 +87,16 @@ export function BranchToolbarBranchSelector({ · 9 unmodified lines | ||
| 85 | 87 | const [branchQuery, setBranchQuery] = useState(""); |
| 86 | 88 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 87 | 89 | |
| 88 | − const branchStatusQuery = useGitStatus(branchCwd); | |
| 90 | + const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd }); | |
| 89 | 91 | const trimmedBranchQuery = branchQuery.trim(); |
| 90 | 92 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); |
| 91 | 93 | |
| 92 | 94 | useEffect(() => { |
| 93 | 95 | if (!branchCwd) return; |
| 94 | 96 | void queryClient.prefetchInfiniteQuery( |
| 95 | − gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }), | |
| 97 | + gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }), | |
| 96 | 98 | ); |
| 97 | − }, [branchCwd, queryClient]); | |
| 99 | + }, [branchCwd, environmentId, queryClient]); | |
| 98 | 100 | |
| 99 | 101 | const { |
| 100 | 102 | data: branchesSearchData, |
| @@ -104,6 +106,7 @@ export function BranchToolbarBranchSelector({ · 3 unmodified lines | ||
| 104 | 106 | isPending: isBranchesSearchPending, |
| 105 | 107 | } = useInfiniteQuery( |
| 106 | 108 | gitBranchSearchInfiniteQueryOptions({ |
| 109 | + environmentId, | |
| 107 | 110 | cwd: branchCwd, |
| 108 | 111 | query: deferredTrimmedBranchQuery, |
| 109 | 112 | }), |
| @@ -184,13 +187,13 @@ export function BranchToolbarBranchSelector({ · 74 unmodified lines | ||
| 184 | 187 | startBranchActionTransition(async () => { |
| 185 | 188 | await action().catch(() => undefined); |
| 186 | 189 | await queryClient |
| 187 | − .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) }) | |
| 190 | + .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) }) | |
| 188 | 191 | .catch(() => undefined); |
| 189 | 192 | }); |
| 190 | 193 | }; |
| 191 | 194 | |
| 192 | 195 | const selectBranch = (branch: GitBranch) => { |
| 193 | − const api = readNativeApi(); | |
| 196 | + const api = readEnvironmentApi(environmentId); | |
| 194 | 197 | if (!api || !branchCwd || isBranchActionPending) return; |
| 195 | 198 | |
| 196 | 199 | // In new-worktree mode, selecting a branch sets the base branch. |
| @@ -248,7 +251,7 @@ export function BranchToolbarBranchSelector({ · 51 unmodified lines | ||
| 248 | 251 | |
| 249 | 252 | const createBranch = (rawName: string) => { |
| 250 | 253 | const name = rawName.trim(); |
| 251 | − const api = readNativeApi(); | |
| 254 | + const api = readEnvironmentApi(environmentId); | |
| 252 | 255 | if (!api || !branchCwd || !name || isBranchActionPending) return; |
| 253 | 256 | |
| 254 | 257 | setIsBranchMenuOpen(false); |
| @@ -302,10 +305,10 @@ export function BranchToolbarBranchSelector({ · 47 unmodified lines | ||
| 302 | 305 | return; |
| 303 | 306 | } |
| 304 | 307 | void queryClient.invalidateQueries({ |
| 305 | − queryKey: gitQueryKeys.branches(branchCwd), | |
| 308 | + queryKey: gitQueryKeys.branches(environmentId, branchCwd), | |
| 306 | 309 | }); |
| 307 | 310 | }, |
| 308 | − [branchCwd, queryClient], | |
| 311 | + [branchCwd, environmentId, queryClient], | |
| 309 | 312 | ); |
| 310 | 313 | |
| 311 | 314 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); |
| @@ -22,7 +22,7 @@ import { fnv1a32 } from "../lib/diffRendering"; · 21 unmodified lines | ||
| 22 | 22 | import { LRUCache } from "../lib/lruCache"; |
| 23 | 23 | import { useTheme } from "../hooks/useTheme"; |
| 24 | 24 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; |
| 25 | −import { readNativeApi } from "../nativeApi"; | |
| 25 | +import { readLocalApi } from "../localApi"; | |
| 26 | 26 | |
| 27 | 27 | class CodeHighlightErrorBoundary extends React.Component< |
| 28 | 28 | { fallback: ReactNode; children: ReactNode }, |
| @@ -253,7 +253,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { · 224 unmodified lines | ||
| 253 | 253 | onClick={(event) => { |
| 254 | 254 | event.preventDefault(); |
| 255 | 255 | event.stopPropagation(); |
| 256 | − const api = readNativeApi(); | |
| 256 | + const api = readLocalApi(); | |
| 257 | 257 | if (api) { |
| 258 | 258 | void openInPreferredEditor(api, targetPath); |
| 259 | 259 | } else { |
| @@ -4,6 +4,7 @@ import "../index.css"; · 3 unmodified lines | ||
| 4 | 4 | import { |
| 5 | 5 | EventId, |
| 6 | 6 | ORCHESTRATION_WS_METHODS, |
| 7 | + EnvironmentId, | |
| 7 | 8 | type MessageId, |
| 8 | 9 | type OrchestrationEvent, |
| 9 | 10 | type OrchestrationReadModel, |
| @@ -12,11 +13,16 @@ import { · 2 unmodified lines | ||
| 12 | 13 | type ServerLifecycleWelcomePayload, |
| 13 | 14 | type ThreadId, |
| 14 | 15 | type TurnId, |
| 15 | − type UserInputQuestion, | |
| 16 | 16 | WS_METHODS, |
| 17 | 17 | OrchestrationSessionStatus, |
| 18 | 18 | DEFAULT_SERVER_SETTINGS, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | +import { | |
| 21 | + scopedProjectKey, | |
| 22 | + scopedThreadKey, | |
| 23 | + scopeProjectRef, | |
| 24 | + scopeThreadRef, | |
| 25 | +} from "@t3tools/client-runtime"; | |
| 20 | 26 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 21 | 27 | import { HttpResponse, http, ws } from "msw"; |
| 22 | 28 | import { setupWorker } from "msw/browser"; |
| @@ -24,19 +30,20 @@ import { page } from "vitest/browser"; · 1 unmodified line | ||
| 24 | 30 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
| 25 | 31 | import { render } from "vitest-browser-react"; |
| 26 | 32 | |
| 27 | −import { useComposerDraftStore } from "../composerDraftStore"; | |
| 33 | +import { useComposerDraftStore, DraftId } from "../composerDraftStore"; | |
| 28 | 34 | import { |
| 29 | 35 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, |
| 30 | 36 | type TerminalContextDraft, |
| 31 | 37 | removeInlineTerminalContextPlaceholder, |
| 32 | 38 | } from "../lib/terminalContext"; |
| 33 | 39 | import { isMacPlatform } from "../lib/utils"; |
| 34 | −import { __resetNativeApiForTests } from "../nativeApi"; | |
| 40 | +import { __resetLocalApiForTests } from "../localApi"; | |
| 35 | 41 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; |
| 36 | 42 | import { getServerConfig } from "../rpc/serverState"; |
| 37 | 43 | import { getRouter } from "../router"; |
| 38 | −import { useStore } from "../store"; | |
| 44 | +import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; | |
| 39 | 45 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 46 | +import { useUiStateStore } from "../uiStateStore"; | |
| 40 | 47 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; |
| 41 | 48 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 42 | 49 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; |
| @@ -49,8 +56,13 @@ vi.mock("../lib/gitStatusState", () => ({ · 6 unmodified lines | ||
| 49 | 56 | })); |
| 50 | 57 | |
| 51 | 58 | const THREAD_ID = "thread-browser-test" as ThreadId; |
| 52 | −const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; | |
| 53 | 59 | const PROJECT_ID = "project-1" as ProjectId; |
| 60 | +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); | |
| 61 | +const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); | |
| 62 | +const THREAD_KEY = scopedThreadKey(THREAD_REF); | |
| 63 | +const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; | |
| 64 | +const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; | |
| 65 | +const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID)); | |
| 54 | 66 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
| 55 | 67 | const BASE_TIME_MS = Date.parse(NOW_ISO); |
| 56 | 68 | const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>"; |
| @@ -129,6 +141,13 @@ function isoAt(offsetSeconds: number): string { · 72 unmodified lines | ||
| 129 | 141 | |
| 130 | 142 | function createBaseServerConfig(): ServerConfig { |
| 131 | 143 | return { |
| 144 | + environment: { | |
| 145 | + environmentId: EnvironmentId.makeUnsafe("environment-local"), | |
| 146 | + label: "Local environment", | |
| 147 | + platform: { os: "darwin" as const, arch: "arm64" as const }, | |
| 148 | + serverVersion: "0.0.0-test", | |
| 149 | + capabilities: { repositoryIdentity: true }, | |
| 150 | + }, | |
| 132 | 151 | cwd: "/repo/project", |
| 133 | 152 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 134 | 153 | keybindings: [], |
| @@ -314,6 +333,13 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { · 179 unmodified lines | ||
| 314 | 333 | snapshot, |
| 315 | 334 | serverConfig: createBaseServerConfig(), |
| 316 | 335 | welcome: { |
| 336 | + environment: { | |
| 337 | + environmentId: EnvironmentId.makeUnsafe("environment-local"), | |
| 338 | + label: "Local environment", | |
| 339 | + platform: { os: "darwin" as const, arch: "arm64" as const }, | |
| 340 | + serverVersion: "0.0.0-test", | |
| 341 | + capabilities: { repositoryIdentity: true }, | |
| 342 | + }, | |
| 317 | 343 | cwd: "/repo/project", |
| 318 | 344 | projectName: "Project", |
| 319 | 345 | bootstrapProjectId: PROJECT_ID, |
| @@ -396,6 +422,33 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest · 76 unmodified lines | ||
| 396 | 422 | }; |
| 397 | 423 | } |
| 398 | 424 | |
| 425 | +function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { | |
| 426 | + return { | |
| 427 | + sequence, | |
| 428 | + eventId: EventId.makeUnsafe(`event-thread-session-set-${sequence}`), | |
| 429 | + aggregateKind: "thread", | |
| 430 | + aggregateId: threadId, | |
| 431 | + occurredAt: NOW_ISO, | |
| 432 | + commandId: null, | |
| 433 | + causationEventId: null, | |
| 434 | + correlationId: null, | |
| 435 | + metadata: {}, | |
| 436 | + type: "thread.session-set", | |
| 437 | + payload: { | |
| 438 | + threadId, | |
| 439 | + session: { | |
| 440 | + threadId, | |
| 441 | + status: "running", | |
| 442 | + providerName: "codex", | |
| 443 | + runtimeMode: "full-access", | |
| 444 | + activeTurnId: `turn-${threadId}` as TurnId, | |
| 445 | + lastError: null, | |
| 446 | + updatedAt: NOW_ISO, | |
| 447 | + }, | |
| 448 | + }, | |
| 449 | + }; | |
| 450 | +} | |
| 451 | + | |
| 399 | 452 | function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { |
| 400 | 453 | rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); |
| 401 | 454 | } |
| @@ -419,25 +472,72 @@ async function waitForWsClient(): Promise<void> { · 17 unmodified lines | ||
| 419 | 472 | ); |
| 420 | 473 | } |
| 421 | 474 | |
| 475 | +function threadRefFor(threadId: ThreadId) { | |
| 476 | + return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); | |
| 477 | +} | |
| 478 | + | |
| 479 | +function threadKeyFor(threadId: ThreadId): string { | |
| 480 | + return scopedThreadKey(threadRefFor(threadId)); | |
| 481 | +} | |
| 482 | + | |
| 483 | +function composerDraftFor(target: string) { | |
| 484 | + const { draftsByThreadKey } = useComposerDraftStore.getState(); | |
| 485 | + return draftsByThreadKey[target] ?? draftsByThreadKey[threadKeyFor(target as ThreadId)]; | |
| 486 | +} | |
| 487 | + | |
| 488 | +function draftIdFromPath(pathname: string) { | |
| 489 | + const segments = pathname.split("/"); | |
| 490 | + const draftId = segments[segments.length - 1]; | |
| 491 | + if (!draftId) { | |
| 492 | + throw new Error(`Expected thread path, received "${pathname}".`); | |
| 493 | + } | |
| 494 | + return DraftId.makeUnsafe(draftId); | |
| 495 | +} | |
| 496 | + | |
| 497 | +function draftThreadIdFor(draftId: ReturnType<typeof draftIdFromPath>): ThreadId { | |
| 498 | + const draftSession = useComposerDraftStore.getState().getDraftSession(draftId); | |
| 499 | + if (!draftSession) { | |
| 500 | + throw new Error(`Expected draft session for "${draftId}".`); | |
| 501 | + } | |
| 502 | + return draftSession.threadId; | |
| 503 | +} | |
| 504 | + | |
| 505 | +function serverThreadPath(threadId: ThreadId): string { | |
| 506 | + return `/${LOCAL_ENVIRONMENT_ID}/${threadId}`; | |
| 507 | +} | |
| 508 | + | |
| 422 | 509 | async function waitForAppBootstrap(): Promise<void> { |
| 423 | 510 | await vi.waitFor( |
| 424 | 511 | () => { |
| 425 | 512 | expect(getServerConfig()).not.toBeNull(); |
| 426 | − expect(useStore.getState().bootstrapComplete).toBe(true); | |
| 513 | + expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true); | |
| 427 | 514 | }, |
| 428 | 515 | { timeout: 8_000, interval: 16 }, |
| 429 | 516 | ); |
| 430 | 517 | } |
| 431 | 518 | |
| 432 | −async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { | |
| 519 | +async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { | |
| 433 | 520 | await waitForWsClient(); |
| 434 | 521 | fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); |
| 435 | 522 | sendOrchestrationDomainEvent( |
| 436 | 523 | createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), |
| 437 | 524 | ); |
| 525 | +} | |
| 526 | + | |
| 527 | +async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise<void> { | |
| 528 | + sendOrchestrationDomainEvent( | |
| 529 | + createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1), | |
| 530 | + ); | |
| 531 | +} | |
| 532 | + | |
| 533 | +async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { | |
| 534 | + await materializePromotedDraftThreadViaDomainEvent(threadId); | |
| 535 | + await startPromotedServerThreadViaDomainEvent(threadId); | |
| 438 | 536 | await vi.waitFor( |
| 439 | 537 | () => { |
| 440 | − expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined(); | |
| 538 | + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[threadKeyFor(threadId)]).toBe( | |
| 539 | + undefined, | |
| 540 | + ); | |
| 441 | 541 | }, |
| 442 | 542 | { timeout: 8_000, interval: 16 }, |
| 443 | 543 | ); |
| @@ -468,9 +568,12 @@ function withProjectScripts( · 24 unmodified lines | ||
| 468 | 568 | |
| 469 | 569 | function setDraftThreadWithoutWorktree(): void { |
| 470 | 570 | useComposerDraftStore.setState({ |
| 471 | − draftThreadsByThreadId: { | |
| 472 | − [THREAD_ID]: { | |
| 571 | + draftThreadsByThreadKey: { | |
| 572 | + [THREAD_KEY]: { | |
| 573 | + threadId: THREAD_ID, | |
| 574 | + environmentId: LOCAL_ENVIRONMENT_ID, | |
| 473 | 575 | projectId: PROJECT_ID, |
| 576 | + logicalProjectKey: PROJECT_DRAFT_KEY, | |
| 474 | 577 | createdAt: NOW_ISO, |
| 475 | 578 | runtimeMode: "full-access", |
| 476 | 579 | interactionMode: "default", |
| @@ -479,8 +582,8 @@ function setDraftThreadWithoutWorktree(): void { · 2 unmodified lines | ||
| 479 | 582 | envMode: "local", |
| 480 | 583 | }, |
| 481 | 584 | }, |
| 482 | − projectDraftThreadIdByProjectId: { | |
| 483 | − [PROJECT_ID]: THREAD_ID, | |
| 585 | + logicalProjectDraftThreadKeyByLogicalProjectKey: { | |
| 586 | + [PROJECT_DRAFT_KEY]: THREAD_KEY, | |
| 484 | 587 | }, |
| 485 | 588 | }); |
| 486 | 589 | } |
| @@ -542,51 +645,12 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { · 55 unmodified lines | ||
| 542 | 645 | }; |
| 543 | 646 | } |
| 544 | 647 | |
| 545 | −function createSnapshotWithPendingUserInput(options?: { | |
| 546 | − questions?: ReadonlyArray<UserInputQuestion>; | |
| 547 | −}): OrchestrationReadModel { | |
| 648 | +function createSnapshotWithPendingUserInput(): OrchestrationReadModel { | |
| 548 | 649 | const snapshot = createSnapshotForTargetUser({ |
| 549 | 650 | targetMessageId: "msg-user-pending-input-target" as MessageId, |
| 550 | 651 | targetText: "question thread", |
| 551 | 652 | }); |
| 552 | 653 | |
| 553 | − const questions = | |
| 554 | − options?.questions ?? | |
| 555 | − ([ | |
| 556 | − { | |
| 557 | − id: "scope", | |
| 558 | − header: "Scope", | |
| 559 | − question: "What should this change cover?", | |
| 560 | − options: [ | |
| 561 | − { | |
| 562 | − label: "Tight", | |
| 563 | − description: "Touch only the footer layout logic.", | |
| 564 | − }, | |
| 565 | − { | |
| 566 | − label: "Broad", | |
| 567 | − description: "Also adjust the related composer controls.", | |
| 568 | − }, | |
| 569 | − ], | |
| 570 | − multiSelect: false, | |
| 571 | − }, | |
| 572 | − { | |
| 573 | − id: "risk", | |
| 574 | − header: "Risk", | |
| 575 | − question: "How aggressive should the imaginary plan be?", | |
| 576 | − options: [ | |
| 577 | − { | |
| 578 | − label: "Conservative", | |
| 579 | − description: "Favor reliability and low-risk changes.", | |
| 580 | − }, | |
| 581 | − { | |
| 582 | − label: "Balanced", | |
| 583 | − description: "Mix quick wins with one structural improvement.", | |
| 584 | − }, | |
| 585 | − ], | |
| 586 | − multiSelect: false, | |
| 587 | − }, | |
| 588 | − ] satisfies ReadonlyArray<UserInputQuestion>); | |
| 589 | − | |
| 590 | 654 | return { |
| 591 | 655 | ...snapshot, |
| 592 | 656 | threads: snapshot.threads.map((thread) => |
| @@ -601,7 +665,38 @@ function createSnapshotWithPendingUserInput(options?: { · 8 unmodified lines | ||
| 601 | 665 | summary: "User input requested", |
| 602 | 666 | payload: { |
| 603 | 667 | requestId: "req-browser-user-input", |
| 604 | − questions, | |
| 668 | + questions: [ | |
| 669 | + { | |
| 670 | + id: "scope", | |
| 671 | + header: "Scope", | |
| 672 | + question: "What should this change cover?", | |
| 673 | + options: [ | |
| 674 | + { | |
| 675 | + label: "Tight", | |
| 676 | + description: "Touch only the footer layout logic.", | |
| 677 | + }, | |
| 678 | + { | |
| 679 | + label: "Broad", | |
| 680 | + description: "Also adjust the related composer controls.", | |
| 681 | + }, | |
| 682 | + ], | |
| 683 | + }, | |
| 684 | + { | |
| 685 | + id: "risk", | |
| 686 | + header: "Risk", | |
| 687 | + question: "How aggressive should the imaginary plan be?", | |
| 688 | + options: [ | |
| 689 | + { | |
| 690 | + label: "Conservative", | |
| 691 | + description: "Favor reliability and low-risk changes.", | |
| 692 | + }, | |
| 693 | + { | |
| 694 | + label: "Balanced", | |
| 695 | + description: "Mix quick wins with one structural improvement.", | |
| 696 | + }, | |
| 697 | + ], | |
| 698 | + }, | |
| 699 | + ], | |
| 605 | 700 | }, |
| 606 | 701 | turnId: null, |
| 607 | 702 | sequence: 1, |
| @@ -1057,7 +1152,7 @@ async function mountChatView(options: { · 449 unmodified lines | ||
| 1057 | 1152 | |
| 1058 | 1153 | const router = getRouter( |
| 1059 | 1154 | createMemoryHistory({ |
| 1060 | − initialEntries: [`/${THREAD_ID}`], | |
| 1155 | + initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], | |
| 1061 | 1156 | }), |
| 1062 | 1157 | ); |
| 1063 | 1158 | |
| @@ -1162,42 +1257,32 @@ describe("ChatView timeline estimator parity (full app)", () => { · 98 unmodified lines | ||
| 1162 | 1257 | return []; |
| 1163 | 1258 | }, |
| 1164 | 1259 | }); |
| 1165 | − await __resetNativeApiForTests(); | |
| 1260 | + await __resetLocalApiForTests(); | |
| 1166 | 1261 | await setViewport(DEFAULT_VIEWPORT); |
| 1167 | 1262 | localStorage.clear(); |
| 1168 | 1263 | document.body.innerHTML = ""; |
| 1169 | 1264 | wsRequests.length = 0; |
| 1170 | 1265 | customWsRpcResolver = null; |
| 1171 | 1266 | useComposerDraftStore.setState({ |
| 1172 | − draftsByThreadId: {}, | |
| 1173 | − draftThreadsByThreadId: {}, | |
| 1174 | − projectDraftThreadIdByProjectId: {}, | |
| 1267 | + draftsByThreadKey: {}, | |
| 1268 | + draftThreadsByThreadKey: {}, | |
| 1269 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 1175 | 1270 | stickyModelSelectionByProvider: {}, |
| 1176 | 1271 | stickyActiveProvider: null, |
| 1177 | 1272 | }); |
| 1178 | 1273 | useStore.setState({ |
| 1179 | − projectIds: [], | |
| 1180 | − projectById: {}, | |
| 1181 | − threadIds: [], | |
| 1182 | − threadIdsByProjectId: {}, | |
| 1183 | − threadShellById: {}, | |
| 1184 | − threadSessionById: {}, | |
| 1185 | − threadTurnStateById: {}, | |
| 1186 | − messageIdsByThreadId: {}, | |
| 1187 | − messageByThreadId: {}, | |
| 1188 | − activityIdsByThreadId: {}, | |
| 1189 | − activityByThreadId: {}, | |
| 1190 | − proposedPlanIdsByThreadId: {}, | |
| 1191 | − proposedPlanByThreadId: {}, | |
| 1192 | − turnDiffIdsByThreadId: {}, | |
| 1193 | − turnDiffSummaryByThreadId: {}, | |
| 1194 | − sidebarThreadSummaryById: {}, | |
| 1195 | − bootstrapComplete: false, | |
| 1274 | + activeEnvironmentId: null, | |
| 1275 | + environmentStateById: {}, | |
| 1276 | + }); | |
| 1277 | + useUiStateStore.setState({ | |
| 1278 | + projectExpandedById: {}, | |
| 1279 | + projectOrder: [], | |
| 1280 | + threadLastVisitedAtById: {}, | |
| 1196 | 1281 | }); |
| 1197 | 1282 | useTerminalStateStore.persist.clearStorage(); |
| 1198 | 1283 | useTerminalStateStore.setState({ |
| 1199 | − terminalStateByThreadId: {}, | |
| 1200 | − terminalLaunchContextByThreadId: {}, | |
| 1284 | + terminalStateByThreadKey: {}, | |
| 1285 | + terminalLaunchContextByThreadKey: {}, | |
| 1201 | 1286 | terminalEventEntriesByKey: {}, |
| 1202 | 1287 | nextTerminalEventId: 1, |
| 1203 | 1288 | }); |
| @@ -1241,6 +1326,35 @@ describe("ChatView timeline estimator parity (full app)", () => { · 37 unmodified lines | ||
| 1241 | 1326 | }, |
| 1242 | 1327 | ); |
| 1243 | 1328 | |
| 1329 | + it("re-expands the bootstrap project using its scoped key", async () => { | |
| 1330 | + useUiStateStore.setState({ | |
| 1331 | + projectExpandedById: { | |
| 1332 | + [PROJECT_KEY]: false, | |
| 1333 | + }, | |
| 1334 | + projectOrder: [PROJECT_KEY], | |
| 1335 | + threadLastVisitedAtById: {}, | |
| 1336 | + }); | |
| 1337 | + | |
| 1338 | + const mounted = await mountChatView({ | |
| 1339 | + viewport: DEFAULT_VIEWPORT, | |
| 1340 | + snapshot: createSnapshotForTargetUser({ | |
| 1341 | + targetMessageId: "msg-user-bootstrap-project-expand" as MessageId, | |
| 1342 | + targetText: "bootstrap project expand", | |
| 1343 | + }), | |
| 1344 | + }); | |
| 1345 | + | |
| 1346 | + try { | |
| 1347 | + await vi.waitFor( | |
| 1348 | + () => { | |
| 1349 | + expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); | |
| 1350 | + }, | |
| 1351 | + { timeout: 8_000, interval: 16 }, | |
| 1352 | + ); | |
| 1353 | + } finally { | |
| 1354 | + await mounted.cleanup(); | |
| 1355 | + } | |
| 1356 | + }); | |
| 1357 | + | |
| 1244 | 1358 | it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { |
| 1245 | 1359 | const userText = "x".repeat(3_200); |
| 1246 | 1360 | const targetMessageId = "msg-user-target-resize" as MessageId; |
| @@ -1438,8 +1552,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 191 unmodified lines | ||
| 1438 | 1552 | } |
| 1439 | 1553 | |
| 1440 | 1554 | useTerminalStateStore.setState({ |
| 1441 | − terminalStateByThreadId: { | |
| 1442 | − [THREAD_ID]: { | |
| 1555 | + terminalStateByThreadKey: { | |
| 1556 | + [THREAD_KEY]: { | |
| 1443 | 1557 | terminalOpen: true, |
| 1444 | 1558 | terminalHeight: 280, |
| 1445 | 1559 | terminalIds: ["default"], |
| @@ -1449,8 +1563,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 3 unmodified lines | ||
| 1449 | 1563 | activeTerminalGroupId: "group-default", |
| 1450 | 1564 | }, |
| 1451 | 1565 | }, |
| 1452 | − terminalLaunchContextByThreadId: { | |
| 1453 | − [THREAD_ID]: { | |
| 1566 | + terminalLaunchContextByThreadKey: { | |
| 1567 | + [THREAD_KEY]: { | |
| 1454 | 1568 | cwd: "/repo/project", |
| 1455 | 1569 | worktreePath: null, |
| 1456 | 1570 | }, |
| @@ -1696,9 +1810,12 @@ describe("ChatView timeline estimator parity (full app)", () => { · 239 unmodified lines | ||
| 1696 | 1810 | |
| 1697 | 1811 | it("runs project scripts from local draft threads at the project cwd", async () => { |
| 1698 | 1812 | useComposerDraftStore.setState({ |
| 1699 | − draftThreadsByThreadId: { | |
| 1700 | − [THREAD_ID]: { | |
| 1813 | + draftThreadsByThreadKey: { | |
| 1814 | + [THREAD_KEY]: { | |
| 1815 | + threadId: THREAD_ID, | |
| 1816 | + environmentId: LOCAL_ENVIRONMENT_ID, | |
| 1701 | 1817 | projectId: PROJECT_ID, |
| 1818 | + logicalProjectKey: PROJECT_DRAFT_KEY, | |
| 1702 | 1819 | createdAt: NOW_ISO, |
| 1703 | 1820 | runtimeMode: "full-access", |
| 1704 | 1821 | interactionMode: "default", |
| @@ -1707,8 +1824,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines | ||
| 1707 | 1824 | envMode: "local", |
| 1708 | 1825 | }, |
| 1709 | 1826 | }, |
| 1710 | − projectDraftThreadIdByProjectId: { | |
| 1711 | − [PROJECT_ID]: THREAD_ID, | |
| 1827 | + logicalProjectDraftThreadKeyByLogicalProjectKey: { | |
| 1828 | + [PROJECT_DRAFT_KEY]: THREAD_KEY, | |
| 1712 | 1829 | }, |
| 1713 | 1830 | }); |
| 1714 | 1831 | |
| @@ -1772,9 +1889,12 @@ describe("ChatView timeline estimator parity (full app)", () => { · 57 unmodified lines | ||
| 1772 | 1889 | |
| 1773 | 1890 | it("runs project scripts from worktree draft threads at the worktree cwd", async () => { |
| 1774 | 1891 | useComposerDraftStore.setState({ |
| 1775 | − draftThreadsByThreadId: { | |
| 1776 | − [THREAD_ID]: { | |
| 1892 | + draftThreadsByThreadKey: { | |
| 1893 | + [THREAD_KEY]: { | |
| 1894 | + threadId: THREAD_ID, | |
| 1895 | + environmentId: LOCAL_ENVIRONMENT_ID, | |
| 1777 | 1896 | projectId: PROJECT_ID, |
| 1897 | + logicalProjectKey: PROJECT_DRAFT_KEY, | |
| 1778 | 1898 | createdAt: NOW_ISO, |
| 1779 | 1899 | runtimeMode: "full-access", |
| 1780 | 1900 | interactionMode: "default", |
| @@ -1783,8 +1903,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines | ||
| 1783 | 1903 | envMode: "worktree", |
| 1784 | 1904 | }, |
| 1785 | 1905 | }, |
| 1786 | − projectDraftThreadIdByProjectId: { | |
| 1787 | − [PROJECT_ID]: THREAD_ID, | |
| 1906 | + logicalProjectDraftThreadKeyByLogicalProjectKey: { | |
| 1907 | + [PROJECT_DRAFT_KEY]: THREAD_KEY, | |
| 1788 | 1908 | }, |
| 1789 | 1909 | }); |
| 1790 | 1910 | |
| @@ -1835,9 +1955,12 @@ describe("ChatView timeline estimator parity (full app)", () => { · 44 unmodified lines | ||
| 1835 | 1955 | |
| 1836 | 1956 | it("lets the server own setup after preparing a pull request worktree thread", async () => { |
| 1837 | 1957 | useComposerDraftStore.setState({ |
| 1838 | − draftThreadsByThreadId: { | |
| 1839 | − [THREAD_ID]: { | |
| 1958 | + draftThreadsByThreadKey: { | |
| 1959 | + [THREAD_KEY]: { | |
| 1960 | + threadId: THREAD_ID, | |
| 1961 | + environmentId: LOCAL_ENVIRONMENT_ID, | |
| 1840 | 1962 | projectId: PROJECT_ID, |
| 1963 | + logicalProjectKey: PROJECT_DRAFT_KEY, | |
| 1841 | 1964 | createdAt: NOW_ISO, |
| 1842 | 1965 | runtimeMode: "full-access", |
| 1843 | 1966 | interactionMode: "default", |
| @@ -1846,8 +1969,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines | ||
| 1846 | 1969 | envMode: "local", |
| 1847 | 1970 | }, |
| 1848 | 1971 | }, |
| 1849 | − projectDraftThreadIdByProjectId: { | |
| 1850 | − [PROJECT_ID]: THREAD_ID, | |
| 1972 | + logicalProjectDraftThreadKeyByLogicalProjectKey: { | |
| 1973 | + [PROJECT_DRAFT_KEY]: THREAD_KEY, | |
| 1851 | 1974 | }, |
| 1852 | 1975 | }); |
| 1853 | 1976 | |
| @@ -1957,12 +2080,15 @@ describe("ChatView timeline estimator parity (full app)", () => { · 103 unmodified lines | ||
| 1957 | 2080 | |
| 1958 | 2081 | it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { |
| 1959 | 2082 | useTerminalStateStore.setState({ |
| 1960 | − terminalStateByThreadId: {}, | |
| 2083 | + terminalStateByThreadKey: {}, | |
| 1961 | 2084 | }); |
| 1962 | 2085 | useComposerDraftStore.setState({ |
| 1963 | − draftThreadsByThreadId: { | |
| 1964 | − [THREAD_ID]: { | |
| 2086 | + draftThreadsByThreadKey: { | |
| 2087 | + [THREAD_KEY]: { | |
| 2088 | + threadId: THREAD_ID, | |
| 2089 | + environmentId: LOCAL_ENVIRONMENT_ID, | |
| 1965 | 2090 | projectId: PROJECT_ID, |
| 2091 | + logicalProjectKey: PROJECT_DRAFT_KEY, | |
| 1966 | 2092 | createdAt: NOW_ISO, |
| 1967 | 2093 | runtimeMode: "full-access", |
| 1968 | 2094 | interactionMode: "default", |
| @@ -1971,8 +2097,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines | ||
| 1971 | 2097 | envMode: "worktree", |
| 1972 | 2098 | }, |
| 1973 | 2099 | }, |
| 1974 | − projectDraftThreadIdByProjectId: { | |
| 1975 | − [PROJECT_ID]: THREAD_ID, | |
| 2100 | + logicalProjectDraftThreadKeyByLogicalProjectKey: { | |
| 2101 | + [PROJECT_DRAFT_KEY]: THREAD_KEY, | |
| 1976 | 2102 | }, |
| 1977 | 2103 | }); |
| 1978 | 2104 | |
| @@ -1998,7 +2124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 19 unmodified lines | ||
| 1998 | 2124 | }); |
| 1999 | 2125 | |
| 2000 | 2126 | try { |
| 2001 | − useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); | |
| 2127 | + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); | |
| 2002 | 2128 | await waitForLayout(); |
| 2003 | 2129 | |
| 2004 | 2130 | const sendButton = await waitForSendButton(); |
| @@ -2057,12 +2183,15 @@ describe("ChatView timeline estimator parity (full app)", () => { · 52 unmodified lines | ||
| 2057 | 2183 | |
| 2058 | 2184 | it("shows the send state once bootstrap dispatch is in flight", async () => { |
| 2059 | 2185 | useTerminalStateStore.setState({ |
| 2060 | − terminalStateByThreadId: {}, | |
| 2186 | + terminalStateByThreadKey: {}, | |
| 2061 | 2187 | }); |
| 2062 | 2188 | useComposerDraftStore.setState({ |
| 2063 | − draftThreadsByThreadId: { | |
| 2064 | − [THREAD_ID]: { | |
| 2189 | + draftThreadsByThreadKey: { | |
| 2190 | + [THREAD_KEY]: { | |
| 2191 | + threadId: THREAD_ID, | |
| 2192 | + environmentId: LOCAL_ENVIRONMENT_ID, | |
| 2065 | 2193 | projectId: PROJECT_ID, |
| 2194 | + logicalProjectKey: PROJECT_DRAFT_KEY, | |
| 2066 | 2195 | createdAt: NOW_ISO, |
| 2067 | 2196 | runtimeMode: "full-access", |
| 2068 | 2197 | interactionMode: "default", |
| @@ -2071,8 +2200,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 2 unmodified lines | ||
| 2071 | 2200 | envMode: "worktree", |
| 2072 | 2201 | }, |
| 2073 | 2202 | }, |
| 2074 | − projectDraftThreadIdByProjectId: { | |
| 2075 | − [PROJECT_ID]: THREAD_ID, | |
| 2203 | + logicalProjectDraftThreadKeyByLogicalProjectKey: { | |
| 2204 | + [PROJECT_DRAFT_KEY]: THREAD_KEY, | |
| 2076 | 2205 | }, |
| 2077 | 2206 | }); |
| 2078 | 2207 | |
| @@ -2101,7 +2230,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 22 unmodified lines | ||
| 2101 | 2230 | }); |
| 2102 | 2231 | |
| 2103 | 2232 | try { |
| 2104 | − useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); | |
| 2233 | + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); | |
| 2105 | 2234 | await waitForLayout(); |
| 2106 | 2235 | |
| 2107 | 2236 | const sendButton = await waitForSendButton(); |
| @@ -2193,7 +2322,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 85 unmodified lines | ||
| 2193 | 2322 | const removedLabel = "Terminal 1 lines 1-2"; |
| 2194 | 2323 | const addedLabel = "Terminal 2 lines 9-10"; |
| 2195 | 2324 | useComposerDraftStore.getState().addTerminalContext( |
| 2196 | − THREAD_ID, | |
| 2325 | + THREAD_REF, | |
| 2197 | 2326 | createTerminalContext({ |
| 2198 | 2327 | id: "ctx-removed", |
| 2199 | 2328 | terminalLabel: "Terminal 1", |
| @@ -2220,21 +2349,21 @@ describe("ChatView timeline estimator parity (full app)", () => { · 20 unmodified lines | ||
| 2220 | 2349 | ); |
| 2221 | 2350 | |
| 2222 | 2351 | const store = useComposerDraftStore.getState(); |
| 2223 | − const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; | |
| 2352 | + const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; | |
| 2224 | 2353 | const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); |
| 2225 | − store.setPrompt(THREAD_ID, nextPrompt.prompt); | |
| 2226 | − store.removeTerminalContext(THREAD_ID, "ctx-removed"); | |
| 2354 | + store.setPrompt(THREAD_REF, nextPrompt.prompt); | |
| 2355 | + store.removeTerminalContext(THREAD_REF, "ctx-removed"); | |
| 2227 | 2356 | |
| 2228 | 2357 | await vi.waitFor( |
| 2229 | 2358 | () => { |
| 2230 | − expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined(); | |
| 2359 | + expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); | |
| 2231 | 2360 | expect(document.body.textContent).not.toContain(removedLabel); |
| 2232 | 2361 | }, |
| 2233 | 2362 | { timeout: 8_000, interval: 16 }, |
| 2234 | 2363 | ); |
| 2235 | 2364 | |
| 2236 | 2365 | useComposerDraftStore.getState().addTerminalContext( |
| 2237 | − THREAD_ID, | |
| 2366 | + THREAD_REF, | |
| 2238 | 2367 | createTerminalContext({ |
| 2239 | 2368 | id: "ctx-added", |
| 2240 | 2369 | terminalLabel: "Terminal 2", |
| @@ -2246,7 +2375,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 5 unmodified lines | ||
| 2246 | 2375 | |
| 2247 | 2376 | await vi.waitFor( |
| 2248 | 2377 | () => { |
| 2249 | − const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]; | |
| 2378 | + const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; | |
| 2250 | 2379 | expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); |
| 2251 | 2380 | expect(document.body.textContent).toContain(addedLabel); |
| 2252 | 2381 | expect(document.body.textContent).not.toContain(removedLabel); |
| @@ -2261,7 +2390,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 8 unmodified lines | ||
| 2261 | 2390 | it("disables send when the composer only contains an expired terminal pill", async () => { |
| 2262 | 2391 | const expiredLabel = "Terminal 1 line 4"; |
| 2263 | 2392 | useComposerDraftStore.getState().addTerminalContext( |
| 2264 | − THREAD_ID, | |
| 2393 | + THREAD_REF, | |
| 2265 | 2394 | createTerminalContext({ |
| 2266 | 2395 | id: "ctx-expired-only", |
| 2267 | 2396 | terminalLabel: "Terminal 1", |
| @@ -2297,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 29 unmodified lines | ||
| 2297 | 2426 | it("warns when sending text while omitting expired terminal pills", async () => { |
| 2298 | 2427 | const expiredLabel = "Terminal 1 line 4"; |
| 2299 | 2428 | useComposerDraftStore.getState().addTerminalContext( |
| 2300 | − THREAD_ID, | |
| 2429 | + THREAD_REF, | |
| 2301 | 2430 | createTerminalContext({ |
| 2302 | 2431 | id: "ctx-expired-send-warning", |
| 2303 | 2432 | terminalLabel: "Terminal 1", |
| @@ -2308,7 +2437,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 4 unmodified lines | ||
| 2308 | 2437 | ); |
| 2309 | 2438 | useComposerDraftStore |
| 2310 | 2439 | .getState() |
| 2311 | − .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); | |
| 2440 | + .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); | |
| 2312 | 2441 | |
| 2313 | 2442 | const mounted = await mountChatView({ |
| 2314 | 2443 | viewport: DEFAULT_VIEWPORT, |
| @@ -2448,7 +2577,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 133 unmodified lines | ||
| 2448 | 2577 | } |
| 2449 | 2578 | }); |
| 2450 | 2579 | |
| 2451 | − it("keeps the new thread selected after clicking the new-thread button", async () => { | |
| 2580 | + it("canonicalizes promoted draft threads to the server thread route", async () => { | |
| 2452 | 2581 | const mounted = await mountChatView({ |
| 2453 | 2582 | viewport: DEFAULT_VIEWPORT, |
| 2454 | 2583 | snapshot: createSnapshotForTargetUser({ |
| @@ -2470,21 +2599,34 @@ describe("ChatView timeline estimator parity (full app)", () => { · 15 unmodified lines | ||
| 2470 | 2599 | (path) => UUID_ROUTE_RE.test(path), |
| 2471 | 2600 | "Route should have changed to a new draft thread UUID.", |
| 2472 | 2601 | ); |
| 2473 | − const newThreadId = newThreadPath.slice(1) as ThreadId; | |
| 2602 | + const newDraftId = draftIdFromPath(newThreadPath); | |
| 2603 | + const newThreadId = draftThreadIdFor(newDraftId); | |
| 2474 | 2604 | |
| 2475 | 2605 | // The composer editor should be present for the new draft thread. |
| 2476 | 2606 | await waitForComposerEditor(); |
| 2477 | 2607 | |
| 2478 | − // Simulate the steady-state promotion path: the server emits | |
| 2479 | − // `thread.created`, the client materializes the thread incrementally, | |
| 2480 | − // and the draft is cleared by live batch effects. | |
| 2481 | − await promoteDraftThreadViaDomainEvent(newThreadId); | |
| 2608 | + // `thread.created` should only mark the draft as promoting; it should | |
| 2609 | + // not navigate away until the server thread has actual runtime state. | |
| 2610 | + await materializePromotedDraftThreadViaDomainEvent(newThreadId); | |
| 2611 | + expect(mounted.router.state.location.pathname).toBe(newThreadPath); | |
| 2612 | + await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); | |
| 2613 | + | |
| 2614 | + // Once the server thread starts, the route should canonicalize. | |
| 2615 | + await startPromotedServerThreadViaDomainEvent(newThreadId); | |
| 2616 | + await vi.waitFor( | |
| 2617 | + () => { | |
| 2618 | + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[newDraftId]).toBe( | |
| 2619 | + undefined, | |
| 2620 | + ); | |
| 2621 | + }, | |
| 2622 | + { timeout: 8_000, interval: 16 }, | |
| 2623 | + ); | |
| 2482 | 2624 | |
| 2483 | − // The route should still be on the new thread — not redirected away. | |
| 2625 | + // The route should switch to the canonical server thread path. | |
| 2484 | 2626 | await waitForURL( |
| 2485 | 2627 | mounted.router, |
| 2486 | − (path) => path === newThreadPath, | |
| 2487 | − "New thread should remain selected after server thread promotion clears the draft.", | |
| 2628 | + (path) => path === serverThreadPath(newThreadId), | |
| 2629 | + "Promoted drafts should canonicalize to the server thread route.", | |
| 2488 | 2630 | ); |
| 2489 | 2631 | |
| 2490 | 2632 | // The empty thread view and composer should still be visible. |
| @@ -2497,6 +2639,48 @@ describe("ChatView timeline estimator parity (full app)", () => { · 6 unmodified lines | ||
| 2497 | 2639 | } |
| 2498 | 2640 | }); |
| 2499 | 2641 | |
| 2642 | + it("canonicalizes stale promoted draft routes to the server thread route", async () => { | |
| 2643 | + const mounted = await mountChatView({ | |
| 2644 | + viewport: DEFAULT_VIEWPORT, | |
| 2645 | + snapshot: createSnapshotForTargetUser({ | |
| 2646 | + targetMessageId: "msg-user-draft-hydration-race-test" as MessageId, | |
| 2647 | + targetText: "draft hydration race test", | |
| 2648 | + }), | |
| 2649 | + }); | |
| 2650 | + | |
| 2651 | + try { | |
| 2652 | + const newThreadButton = page.getByTestId("new-thread-button"); | |
| 2653 | + await expect.element(newThreadButton).toBeInTheDocument(); | |
| 2654 | + | |
| 2655 | + await newThreadButton.click(); | |
| 2656 | + | |
| 2657 | + const newThreadPath = await waitForURL( | |
| 2658 | + mounted.router, | |
| 2659 | + (path) => UUID_ROUTE_RE.test(path), | |
| 2660 | + "Route should have changed to a new draft thread UUID.", | |
| 2661 | + ); | |
| 2662 | + const newDraftId = draftIdFromPath(newThreadPath); | |
| 2663 | + const newThreadId = draftThreadIdFor(newDraftId); | |
| 2664 | + | |
| 2665 | + await promoteDraftThreadViaDomainEvent(newThreadId); | |
| 2666 | + | |
| 2667 | + await mounted.router.navigate({ | |
| 2668 | + to: "/draft/$draftId", | |
| 2669 | + params: { draftId: newDraftId }, | |
| 2670 | + }); | |
| 2671 | + | |
| 2672 | + await waitForURL( | |
| 2673 | + mounted.router, | |
| 2674 | + (path) => path === serverThreadPath(newThreadId), | |
| 2675 | + "Stale promoted draft routes should canonicalize to the server thread path.", | |
| 2676 | + ); | |
| 2677 | + | |
| 2678 | + await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); | |
| 2679 | + } finally { | |
| 2680 | + await mounted.cleanup(); | |
| 2681 | + } | |
| 2682 | + }); | |
| 2683 | + | |
| 2500 | 2684 | it("snapshots sticky codex settings into a new draft thread", async () => { |
| 2501 | 2685 | useComposerDraftStore.setState({ |
| 2502 | 2686 | stickyModelSelectionByProvider: { |
| @@ -2531,9 +2715,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 28 unmodified lines | ||
| 2531 | 2715 | (path) => UUID_ROUTE_RE.test(path), |
| 2532 | 2716 | "Route should have changed to a new draft thread UUID.", |
| 2533 | 2717 | ); |
| 2534 | − const newThreadId = newThreadPath.slice(1) as ThreadId; | |
| 2718 | + const newDraftId = draftIdFromPath(newThreadPath); | |
| 2535 | 2719 | |
| 2536 | − expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ | |
| 2720 | + expect(composerDraftFor(newDraftId)).toMatchObject({ | |
| 2537 | 2721 | modelSelectionByProvider: { |
| 2538 | 2722 | codex: { |
| 2539 | 2723 | provider: "codex", |
| @@ -2584,9 +2768,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 44 unmodified lines | ||
| 2584 | 2768 | (path) => UUID_ROUTE_RE.test(path), |
| 2585 | 2769 | "Route should have changed to a new sticky claude draft thread UUID.", |
| 2586 | 2770 | ); |
| 2587 | − const newThreadId = newThreadPath.slice(1) as ThreadId; | |
| 2771 | + const newDraftId = draftIdFromPath(newThreadPath); | |
| 2588 | 2772 | |
| 2589 | − expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ | |
| 2773 | + expect(composerDraftFor(newDraftId)).toMatchObject({ | |
| 2590 | 2774 | modelSelectionByProvider: { |
| 2591 | 2775 | claudeAgent: { |
| 2592 | 2776 | provider: "claudeAgent", |
| @@ -2624,9 +2808,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 31 unmodified lines | ||
| 2624 | 2808 | (path) => UUID_ROUTE_RE.test(path), |
| 2625 | 2809 | "Route should have changed to a new draft thread UUID.", |
| 2626 | 2810 | ); |
| 2627 | − const newThreadId = newThreadPath.slice(1) as ThreadId; | |
| 2811 | + const newDraftId = draftIdFromPath(newThreadPath); | |
| 2628 | 2812 | |
| 2629 | − expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined(); | |
| 2813 | + expect(composerDraftFor(newDraftId)).toBe(undefined); | |
| 2630 | 2814 | } finally { |
| 2631 | 2815 | await mounted.cleanup(); |
| 2632 | 2816 | } |
| @@ -2666,9 +2850,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 33 unmodified lines | ||
| 2666 | 2850 | (path) => UUID_ROUTE_RE.test(path), |
| 2667 | 2851 | "Route should have changed to a sticky draft thread UUID.", |
| 2668 | 2852 | ); |
| 2669 | − const threadId = threadPath.slice(1) as ThreadId; | |
| 2853 | + const draftId = draftIdFromPath(threadPath); | |
| 2670 | 2854 | |
| 2671 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ | |
| 2855 | + expect(composerDraftFor(draftId)).toMatchObject({ | |
| 2672 | 2856 | modelSelectionByProvider: { |
| 2673 | 2857 | codex: { |
| 2674 | 2858 | provider: "codex", |
| @@ -2681,7 +2865,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 6 unmodified lines | ||
| 2681 | 2865 | activeProvider: "codex", |
| 2682 | 2866 | }); |
| 2683 | 2867 | |
| 2684 | − useComposerDraftStore.getState().setModelSelection(threadId, { | |
| 2868 | + useComposerDraftStore.getState().setModelSelection(draftId, { | |
| 2685 | 2869 | provider: "codex", |
| 2686 | 2870 | model: "gpt-5.4", |
| 2687 | 2871 | options: { |
| @@ -2697,7 +2881,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 9 unmodified lines | ||
| 2697 | 2881 | (path) => path === threadPath, |
| 2698 | 2882 | "New-thread should reuse the existing project draft thread.", |
| 2699 | 2883 | ); |
| 2700 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ | |
| 2884 | + expect(composerDraftFor(draftId)).toMatchObject({ | |
| 2701 | 2885 | modelSelectionByProvider: { |
| 2702 | 2886 | codex: { |
| 2703 | 2887 | provider: "codex", |
| @@ -2804,7 +2988,8 @@ describe("ChatView timeline estimator parity (full app)", () => { · 100 unmodified lines | ||
| 2804 | 2988 | (path) => UUID_ROUTE_RE.test(path), |
| 2805 | 2989 | "Route should have changed to a promoted draft thread UUID.", |
| 2806 | 2990 | ); |
| 2807 | − const promotedThreadId = promotedThreadPath.slice(1) as ThreadId; | |
| 2991 | + const promotedDraftId = draftIdFromPath(promotedThreadPath); | |
| 2992 | + const promotedThreadId = draftThreadIdFor(promotedDraftId); | |
| 2808 | 2993 | |
| 2809 | 2994 | await promoteDraftThreadViaDomainEvent(promotedThreadId); |
| 2810 | 2995 | |
| @@ -2925,70 +3110,10 @@ describe("ChatView timeline estimator parity (full app)", () => { · 114 unmodified lines | ||
| 2925 | 3110 | } |
| 2926 | 3111 | }); |
| 2927 | 3112 | |
| 2928 | − it("does not trigger numeric option shortcuts while the composer is focused", async () => { | |
| 3113 | + it("submits pending user input after the final option selection resolves the draft answers", async () => { | |
| 2929 | 3114 | const mounted = await mountChatView({ |
| 2930 | − viewport: WIDE_FOOTER_VIEWPORT, | |
| 3115 | + viewport: DEFAULT_VIEWPORT, | |
| 2931 | 3116 | snapshot: createSnapshotWithPendingUserInput(), |
| 2932 | − }); | |
| 2933 | − | |
| 2934 | − try { | |
| 2935 | − const composerEditor = await waitForComposerEditor(); | |
| 2936 | − composerEditor.focus(); | |
| 2937 | − | |
| 2938 | − const event = new KeyboardEvent("keydown", { | |
| 2939 | − key: "2", | |
| 2940 | − bubbles: true, | |
| 2941 | − cancelable: true, | |
| 2942 | − }); | |
| 2943 | − composerEditor.dispatchEvent(event); | |
| 2944 | − await waitForLayout(); | |
| 2945 | − | |
| 2946 | − expect(event.defaultPrevented).toBe(false); | |
| 2947 | − expect(document.body.textContent).toContain("What should this change cover?"); | |
| 2948 | − expect(document.body.textContent).not.toContain( | |
| 2949 | − "How aggressive should the imaginary plan be?", | |
| 2950 | − ); | |
| 2951 | − await waitForButtonByText("Next question"); | |
| 2952 | − } finally { | |
| 2953 | − await mounted.cleanup(); | |
| 2954 | − } | |
| 2955 | − }); | |
| 2956 | − | |
| 2957 | − it("submits multi-select questionnaire answers as arrays", async () => { | |
| 2958 | − const mounted = await mountChatView({ | |
| 2959 | − viewport: WIDE_FOOTER_VIEWPORT, | |
| 2960 | − snapshot: createSnapshotWithPendingUserInput({ | |
| 2961 | − questions: [ | |
| 2962 | − { | |
| 2963 | − id: "scope", | |
| 2964 | − header: "Scope", | |
| 2965 | − question: "Which areas should this change cover?", | |
| 2966 | − options: [ | |
| 2967 | − { | |
| 2968 | − label: "Server", | |
| 2969 | − description: "Touch server orchestration.", | |
| 2970 | − }, | |
| 2971 | − { | |
| 2972 | − label: "Web", | |
| 2973 | − description: "Touch the browser UI.", | |
| 2974 | − }, | |
| 2975 | − ], | |
| 2976 | − multiSelect: true, | |
| 2977 | − }, | |
| 2978 | − { | |
| 2979 | − id: "risk", | |
| 2980 | − header: "Risk", | |
| 2981 | − question: "How aggressive should the imaginary plan be?", | |
| 2982 | − options: [ | |
| 2983 | − { | |
| 2984 | − label: "Balanced", | |
| 2985 | − description: "Mix quick wins with one structural improvement.", | |
| 2986 | − }, | |
| 2987 | − ], | |
| 2988 | − multiSelect: false, | |
| 2989 | − }, | |
| 2990 | − ], | |
| 2991 | − }), | |
| 2992 | 3117 | resolveRpc: (body) => { |
| 2993 | 3118 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { |
| 2994 | 3119 | return { |
| @@ -3000,37 +3125,11 @@ describe("ChatView timeline estimator parity (full app)", () => { · 5 unmodified lines | ||
| 3000 | 3125 | }); |
| 3001 | 3126 | |
| 3002 | 3127 | try { |
| 3003 | − const serverOption = await waitForButtonContainingText("Server"); | |
| 3004 | − serverOption.click(); | |
| 3005 | − await waitForLayout(); | |
| 3006 | − | |
| 3007 | − expect(document.body.textContent).toContain("Which areas should this change cover?"); | |
| 3008 | − | |
| 3009 | − const webOption = await waitForButtonContainingText("Web"); | |
| 3010 | − webOption.click(); | |
| 3011 | − await waitForLayout(); | |
| 3012 | − | |
| 3013 | − expect(document.body.textContent).toContain("Which areas should this change cover?"); | |
| 3014 | − | |
| 3015 | − const nextButton = await waitForButtonByText("Next question"); | |
| 3016 | − expect(nextButton.disabled).toBe(false); | |
| 3017 | − nextButton.click(); | |
| 3018 | − | |
| 3019 | − await vi.waitFor( | |
| 3020 | − () => { | |
| 3021 | − expect(document.body.textContent).toContain( | |
| 3022 | − "How aggressive should the imaginary plan be?", | |
| 3023 | − ); | |
| 3024 | − }, | |
| 3025 | − { timeout: 8_000, interval: 16 }, | |
| 3026 | − ); | |
| 3027 | − | |
| 3028 | − const balancedOption = await waitForButtonContainingText("Balanced"); | |
| 3029 | − balancedOption.click(); | |
| 3128 | + const firstOption = await waitForButtonContainingText("Tight"); | |
| 3129 | + firstOption.click(); | |
| 3030 | 3130 | |
| 3031 | − const submitButton = await waitForButtonByText("Submit answers"); | |
| 3032 | − expect(submitButton.disabled).toBe(false); | |
| 3033 | − submitButton.click(); | |
| 3131 | + const finalOption = await waitForButtonContainingText("Conservative"); | |
| 3132 | + finalOption.click(); | |
| 3034 | 3133 | |
| 3035 | 3134 | await vi.waitFor( |
| 3036 | 3135 | () => { |
| @@ -3042,6 +3141,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 5 unmodified lines | ||
| 3042 | 3141 | | { |
| 3043 | 3142 | _tag: string; |
| 3044 | 3143 | type?: string; |
| 3144 | + requestId?: string; | |
| 3045 | 3145 | answers?: Record<string, unknown>; |
| 3046 | 3146 | } |
| 3047 | 3147 | | undefined; |
| @@ -3049,9 +3149,10 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1 unmodified line | ||
| 3049 | 3149 | expect(dispatchRequest).toMatchObject({ |
| 3050 | 3150 | _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, |
| 3051 | 3151 | type: "thread.user-input.respond", |
| 3152 | + requestId: "req-browser-user-input", | |
| 3052 | 3153 | answers: { |
| 3053 | − scope: ["Server", "Web"], | |
| 3054 | − risk: "Balanced", | |
| 3154 | + scope: "Tight", | |
| 3155 | + risk: "Conservative", | |
| 3055 | 3156 | }, |
| 3056 | 3157 | }); |
| 3057 | 3158 | }, |
| @@ -1,6 +1,7 @@ | ||
| 1 | −import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 1 | +import { scopeThreadRef } from "@t3tools/client-runtime"; | |
| 2 | +import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 2 | 3 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 3 | −import { useStore } from "../store"; | |
| 4 | +import { type EnvironmentState, useStore } from "../store"; | |
| 4 | 5 | import { type Thread } from "../types"; |
| 5 | 6 | |
| 6 | 7 | import { |
| @@ -10,9 +11,12 @@ import { · 3 unmodified lines | ||
| 10 | 11 | deriveComposerSendState, |
| 11 | 12 | hasServerAcknowledgedLocalDispatch, |
| 12 | 13 | reconcileMountedTerminalThreadIds, |
| 14 | + shouldWriteThreadErrorToCurrentServerThread, | |
| 13 | 15 | waitForStartedServerThread, |
| 14 | 16 | } from "./ChatView.logic"; |
| 15 | 17 | |
| 18 | +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); | |
| 19 | + | |
| 16 | 20 | describe("deriveComposerSendState", () => { |
| 17 | 21 | it("treats expired terminal pills as non-sendable content", () => { |
| 18 | 22 | const state = deriveComposerSendState({ |
| @@ -170,6 +174,36 @@ describe("reconcileMountedTerminalThreadIds", () => { · 151 unmodified lines | ||
| 170 | 174 | }); |
| 171 | 175 | }); |
| 172 | 176 | |
| 177 | +describe("shouldWriteThreadErrorToCurrentServerThread", () => { | |
| 178 | + it("routes errors to the active server thread when route and target match", () => { | |
| 179 | + const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 180 | + const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId); | |
| 181 | + | |
| 182 | + expect( | |
| 183 | + shouldWriteThreadErrorToCurrentServerThread({ | |
| 184 | + serverThread: { | |
| 185 | + environmentId: localEnvironmentId, | |
| 186 | + id: threadId, | |
| 187 | + }, | |
| 188 | + routeThreadRef, | |
| 189 | + targetThreadId: threadId, | |
| 190 | + }), | |
| 191 | + ).toBe(true); | |
| 192 | + }); | |
| 193 | + | |
| 194 | + it("does not route draft-thread errors into server-backed state", () => { | |
| 195 | + const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 196 | + | |
| 197 | + expect( | |
| 198 | + shouldWriteThreadErrorToCurrentServerThread({ | |
| 199 | + serverThread: undefined, | |
| 200 | + routeThreadRef: scopeThreadRef(localEnvironmentId, threadId), | |
| 201 | + targetThreadId: threadId, | |
| 202 | + }), | |
| 203 | + ).toBe(false); | |
| 204 | + }); | |
| 205 | +}); | |
| 206 | + | |
| 173 | 207 | const makeThread = (input?: { |
| 174 | 208 | id?: ThreadId; |
| 175 | 209 | latestTurn?: { |
| @@ -181,6 +215,7 @@ const makeThread = (input?: { · 5 unmodified lines | ||
| 181 | 215 | } | null; |
| 182 | 216 | }): Thread => ({ |
| 183 | 217 | id: input?.id ?? ThreadId.makeUnsafe("thread-1"), |
| 218 | + environmentId: localEnvironmentId, | |
| 184 | 219 | codexThreadId: null, |
| 185 | 220 | projectId: ProjectId.makeUnsafe("project-1"), |
| 186 | 221 | title: "Thread", |
| @@ -208,11 +243,12 @@ const makeThread = (input?: { · 21 unmodified lines | ||
| 208 | 243 | |
| 209 | 244 | function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) { |
| 210 | 245 | const projectId = ProjectId.makeUnsafe("project-1"); |
| 211 | − useStore.setState({ | |
| 246 | + const environmentState: EnvironmentState = { | |
| 212 | 247 | projectIds: [projectId], |
| 213 | 248 | projectById: { |
| 214 | 249 | [projectId]: { |
| 215 | 250 | id: projectId, |
| 251 | + environmentId: localEnvironmentId, | |
| 216 | 252 | name: "Project", |
| 217 | 253 | cwd: "/tmp/project", |
| 218 | 254 | defaultModelSelection: { |
| @@ -233,6 +269,7 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) · 14 unmodified lines | ||
| 233 | 269 | thread.id, |
| 234 | 270 | { |
| 235 | 271 | id: thread.id, |
| 272 | + environmentId: thread.environmentId, | |
| 236 | 273 | codexThreadId: thread.codexThreadId, |
| 237 | 274 | projectId: thread.projectId, |
| 238 | 275 | title: thread.title, |
| @@ -301,6 +338,12 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) · 62 unmodified lines | ||
| 301 | 338 | ), |
| 302 | 339 | sidebarThreadSummaryById: {}, |
| 303 | 340 | bootstrapComplete: true, |
| 341 | + }; | |
| 342 | + useStore.setState({ | |
| 343 | + activeEnvironmentId: localEnvironmentId, | |
| 344 | + environmentStateById: { | |
| 345 | + [localEnvironmentId]: environmentState, | |
| 346 | + }, | |
| 304 | 347 | }); |
| 305 | 348 | } |
| 306 | 349 | |
| @@ -326,14 +369,16 @@ describe("waitForStartedServerThread", () => { · 19 unmodified lines | ||
| 326 | 369 | }), |
| 327 | 370 | ]); |
| 328 | 371 | |
| 329 | − await expect(waitForStartedServerThread(threadId)).resolves.toBe(true); | |
| 372 | + await expect( | |
| 373 | + waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)), | |
| 374 | + ).resolves.toBe(true); | |
| 330 | 375 | }); |
| 331 | 376 | |
| 332 | 377 | it("waits for the thread to start via subscription updates", async () => { |
| 333 | 378 | const threadId = ThreadId.makeUnsafe("thread-wait"); |
| 334 | 379 | setStoreThreads([makeThread({ id: threadId })]); |
| 335 | 380 | |
| 336 | − const promise = waitForStartedServerThread(threadId, 500); | |
| 381 | + const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); | |
| 337 | 382 | |
| 338 | 383 | setStoreThreads([ |
| 339 | 384 | makeThread({ |
| @@ -376,7 +421,9 @@ describe("waitForStartedServerThread", () => { · 36 unmodified lines | ||
| 376 | 421 | return originalSubscribe(listener); |
| 377 | 422 | }); |
| 378 | 423 | |
| 379 | − await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true); | |
| 424 | + await expect( | |
| 425 | + waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500), | |
| 426 | + ).resolves.toBe(true); | |
| 380 | 427 | }); |
| 381 | 428 | |
| 382 | 429 | it("returns false after the timeout when the thread never starts", async () => { |
| @@ -384,7 +431,7 @@ describe("waitForStartedServerThread", () => { · 1 unmodified line | ||
| 384 | 431 | |
| 385 | 432 | const threadId = ThreadId.makeUnsafe("thread-timeout"); |
| 386 | 433 | setStoreThreads([makeThread({ id: threadId })]); |
| 387 | − const promise = waitForStartedServerThread(threadId, 500); | |
| 434 | + const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); | |
| 388 | 435 | |
| 389 | 436 | await vi.advanceTimersByTimeAsync(500); |
| 390 | 437 | |
| @@ -414,6 +461,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 23 unmodified lines | ||
| 414 | 461 | it("does not clear local dispatch before server state changes", () => { |
| 415 | 462 | const localDispatch = createLocalDispatchSnapshot({ |
| 416 | 463 | id: ThreadId.makeUnsafe("thread-1"), |
| 464 | + environmentId: localEnvironmentId, | |
| 417 | 465 | codexThreadId: null, |
| 418 | 466 | projectId, |
| 419 | 467 | title: "Thread", |
| @@ -450,6 +498,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 30 unmodified lines | ||
| 450 | 498 | it("clears local dispatch when a new turn is already settled", () => { |
| 451 | 499 | const localDispatch = createLocalDispatchSnapshot({ |
| 452 | 500 | id: ThreadId.makeUnsafe("thread-1"), |
| 501 | + environmentId: localEnvironmentId, | |
| 453 | 502 | codexThreadId: null, |
| 454 | 503 | projectId, |
| 455 | 504 | title: "Thread", |
| @@ -495,6 +544,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 39 unmodified lines | ||
| 495 | 544 | it("clears local dispatch when the session changes without an observed running phase", () => { |
| 496 | 545 | const localDispatch = createLocalDispatchSnapshot({ |
| 497 | 546 | id: ThreadId.makeUnsafe("thread-1"), |
| 547 | + environmentId: localEnvironmentId, | |
| 498 | 548 | codexThreadId: null, |
| 499 | 549 | projectId, |
| 500 | 550 | title: "Thread", |
| @@ -1,9 +1,16 @@ | ||
| 1 | −import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts"; | |
| 1 | +import { | |
| 2 | + type EnvironmentId, | |
| 3 | + ProjectId, | |
| 4 | + type ModelSelection, | |
| 5 | + type ScopedThreadRef, | |
| 6 | + type ThreadId, | |
| 7 | + type TurnId, | |
| 8 | +} from "@t3tools/contracts"; | |
| 2 | 9 | import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; |
| 3 | 10 | import { randomUUID } from "~/lib/utils"; |
| 4 | 11 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 5 | 12 | import { Schema } from "effect"; |
| 6 | −import { selectThreadById, useStore } from "../store"; | |
| 13 | +import { selectThreadByRef, useStore } from "../store"; | |
| 7 | 14 | import { |
| 8 | 15 | filterTerminalContextsWithText, |
| 9 | 16 | stripInlineTerminalContextPlaceholders, |
| @@ -24,6 +31,7 @@ export function buildLocalDraftThread( · 14 unmodified lines | ||
| 24 | 31 | ): Thread { |
| 25 | 32 | return { |
| 26 | 33 | id: threadId, |
| 34 | + environmentId: draftThread.environmentId, | |
| 27 | 35 | codexThreadId: null, |
| 28 | 36 | projectId: draftThread.projectId, |
| 29 | 37 | title: "New thread", |
| @@ -44,13 +52,32 @@ export function buildLocalDraftThread( · 14 unmodified lines | ||
| 44 | 52 | }; |
| 45 | 53 | } |
| 46 | 54 | |
| 55 | +export function shouldWriteThreadErrorToCurrentServerThread(input: { | |
| 56 | + serverThread: | |
| 57 | + | { | |
| 58 | + environmentId: EnvironmentId; | |
| 59 | + id: ThreadId; | |
| 60 | + } | |
| 61 | + | null | |
| 62 | + | undefined; | |
| 63 | + routeThreadRef: ScopedThreadRef; | |
| 64 | + targetThreadId: ThreadId; | |
| 65 | +}): boolean { | |
| 66 | + return Boolean( | |
| 67 | + input.serverThread && | |
| 68 | + input.targetThreadId === input.routeThreadRef.threadId && | |
| 69 | + input.serverThread.environmentId === input.routeThreadRef.environmentId && | |
| 70 | + input.serverThread.id === input.targetThreadId, | |
| 71 | + ); | |
| 72 | +} | |
| 73 | + | |
| 47 | 74 | export function reconcileMountedTerminalThreadIds(input: { |
| 48 | − currentThreadIds: ReadonlyArray<ThreadId>; | |
| 49 | − openThreadIds: ReadonlyArray<ThreadId>; | |
| 50 | − activeThreadId: ThreadId | null; | |
| 75 | + currentThreadIds: ReadonlyArray<string>; | |
| 76 | + openThreadIds: ReadonlyArray<string>; | |
| 77 | + activeThreadId: string | null; | |
| 51 | 78 | activeThreadTerminalOpen: boolean; |
| 52 | 79 | maxHiddenThreadCount?: number; |
| 53 | −}): ThreadId[] { | |
| 80 | +}): string[] { | |
| 54 | 81 | const openThreadIdSet = new Set(input.openThreadIds); |
| 55 | 82 | const hiddenThreadIds = input.currentThreadIds.filter( |
| 56 | 83 | (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), |
| @@ -199,10 +226,10 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { · 142 unmodified lines | ||
| 199 | 226 | } |
| 200 | 227 | |
| 201 | 228 | export async function waitForStartedServerThread( |
| 202 | − threadId: ThreadId, | |
| 229 | + threadRef: ScopedThreadRef, | |
| 203 | 230 | timeoutMs = 1_000, |
| 204 | 231 | ): Promise<boolean> { |
| 205 | − const getThread = () => selectThreadById(threadId)(useStore.getState()); | |
| 232 | + const getThread = () => selectThreadByRef(useStore.getState(), threadRef); | |
| 206 | 233 | const thread = getThread(); |
| 207 | 234 | |
| 208 | 235 | if (threadHasStarted(thread)) { |
| @@ -225,7 +252,7 @@ export async function waitForStartedServerThread( · 16 unmodified lines | ||
| 225 | 252 | }; |
| 226 | 253 | |
| 227 | 254 | const unsubscribe = useStore.subscribe((state) => { |
| 228 | − if (!threadHasStarted(selectThreadById(threadId)(state))) { | |
| 255 | + if (!threadHasStarted(selectThreadByRef(state, threadRef))) { | |
| 229 | 256 | return; |
| 230 | 257 | } |
| 231 | 258 | finish(true); |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | type ApprovalRequestId, |
| 3 | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | 4 | type ClaudeCodeEffort, |
| 5 | + type EnvironmentId, | |
| 5 | 6 | type MessageId, |
| 6 | 7 | type ModelSelection, |
| 7 | 8 | type ProjectScript, |
| @@ -12,6 +13,7 @@ import { · 4 unmodified lines | ||
| 12 | 13 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 13 | 14 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 14 | 15 | type ServerProvider, |
| 16 | + type ScopedThreadRef, | |
| 15 | 17 | type ThreadId, |
| 16 | 18 | type TurnId, |
| 17 | 19 | type KeybindingCommand, |
| @@ -20,6 +22,12 @@ import { · 2 unmodified lines | ||
| 20 | 22 | RuntimeMode, |
| 21 | 23 | TerminalOpenInput, |
| 22 | 24 | } from "@t3tools/contracts"; |
| 25 | +import { | |
| 26 | + parseScopedThreadKey, | |
| 27 | + scopedThreadKey, | |
| 28 | + scopeProjectRef, | |
| 29 | + scopeThreadRef, | |
| 30 | +} from "@t3tools/client-runtime"; | |
| 23 | 31 | import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; |
| 24 | 32 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 25 | 33 | import { truncate } from "@t3tools/shared/String"; |
| @@ -27,9 +35,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr · 1 unmodified line | ||
| 27 | 35 | import { useQuery } from "@tanstack/react-query"; |
| 28 | 36 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 29 | 37 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 38 | +import { useShallow } from "zustand/react/shallow"; | |
| 30 | 39 | import { useGitStatus } from "~/lib/gitStatusState"; |
| 31 | 40 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 41 | +import { readEnvironmentApi } from "../environmentApi"; | |
| 32 | 42 | import { isElectron } from "../env"; |
| 43 | +import { readLocalApi } from "../localApi"; | |
| 33 | 44 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 34 | 45 | import { |
| 35 | 46 | clampCollapsedComposerCursor, |
| @@ -64,8 +75,8 @@ import { · 28 unmodified lines | ||
| 64 | 75 | togglePendingUserInputOptionSelection, |
| 65 | 76 | type PendingUserInputDraftAnswer, |
| 66 | 77 | } from "../pendingUserInput"; |
| 67 | −import { useStore } from "../store"; | |
| 68 | −import { createThreadSelector } from "../storeSelectors"; | |
| 78 | +import { selectThreadsAcrossEnvironments, useStore } from "../store"; | |
| 79 | +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | |
| 69 | 80 | import { useUiStateStore } from "../uiStateStore"; |
| 70 | 81 | import { |
| 71 | 82 | buildPlanImplementationThreadTitle, |
| @@ -114,8 +125,7 @@ import { · 42 unmodified lines | ||
| 114 | 125 | projectScriptIdFromCommand, |
| 115 | 126 | } from "~/projectScripts"; |
| 116 | 127 | import { SidebarTrigger } from "./ui/sidebar"; |
| 117 | −import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; | |
| 118 | −import { readNativeApi } from "~/nativeApi"; | |
| 128 | +import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; | |
| 119 | 129 | import { |
| 120 | 130 | getProviderModelCapabilities, |
| 121 | 131 | getProviderModels, |
| @@ -124,6 +134,8 @@ import { · 2 unmodified lines | ||
| 124 | 134 | import { useSettings } from "../hooks/useSettings"; |
| 125 | 135 | import { resolveAppModelSelection } from "../modelSelection"; |
| 126 | 136 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 137 | +import { deriveLogicalProjectKey } from "../logicalProject"; | |
| 138 | +import { buildDraftThreadRouteParams } from "../threadRoutes"; | |
| 127 | 139 | import { |
| 128 | 140 | type ComposerImageAttachment, |
| 129 | 141 | type DraftThreadEnvMode, |
| @@ -131,6 +143,7 @@ import { · 1 unmodified line | ||
| 131 | 143 | useComposerDraftStore, |
| 132 | 144 | useEffectiveComposerModelState, |
| 133 | 145 | useComposerThreadDraft, |
| 146 | + type DraftId, | |
| 134 | 147 | } from "../composerDraftStore"; |
| 135 | 148 | import { |
| 136 | 149 | appendTerminalContextsToPrompt, |
| @@ -187,6 +200,7 @@ import { · 50 unmodified lines | ||
| 187 | 200 | reconcileMountedTerminalThreadIds, |
| 188 | 201 | revokeBlobPreviewUrl, |
| 189 | 202 | revokeUserMessagePreviewUrls, |
| 203 | + shouldWriteThreadErrorToCurrentServerThread, | |
| 190 | 204 | threadHasStarted, |
| 191 | 205 | waitForStartedServerThread, |
| 192 | 206 | } from "./ChatView.logic"; |
| @@ -203,6 +217,7 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES · 10 unmodified lines | ||
| 203 | 217 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 204 | 218 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 205 | 219 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 220 | +const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; | |
| 206 | 221 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 207 | 222 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 208 | 223 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| @@ -210,26 +225,115 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw · 1 unmodified line | ||
| 210 | 225 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; |
| 211 | 226 | |
| 212 | 227 | function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { |
| 213 | − const threadShellById = useStore((state) => state.threadShellById); | |
| 214 | − const proposedPlanIdsByThreadId = useStore((state) => state.proposedPlanIdsByThreadId); | |
| 215 | − const proposedPlanByThreadId = useStore((state) => state.proposedPlanByThreadId); | |
| 228 | + return useStore( | |
| 229 | + useMemo(() => { | |
| 230 | + let previousThreadIds: readonly ThreadId[] = []; | |
| 231 | + let previousResult: ThreadPlanCatalogEntry[] = []; | |
| 232 | + let previousEntries = new Map< | |
| 233 | + ThreadId, | |
| 234 | + { | |
| 235 | + shell: object | null; | |
| 236 | + proposedPlanIds: readonly string[] | undefined; | |
| 237 | + proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined; | |
| 238 | + entry: ThreadPlanCatalogEntry; | |
| 239 | + } | |
| 240 | + >(); | |
| 241 | + | |
| 242 | + return (state) => { | |
| 243 | + const sameThreadIds = | |
| 244 | + previousThreadIds.length === threadIds.length && | |
| 245 | + previousThreadIds.every((id, index) => id === threadIds[index]); | |
| 246 | + const nextEntries = new Map< | |
| 247 | + ThreadId, | |
| 248 | + { | |
| 249 | + shell: object | null; | |
| 250 | + proposedPlanIds: readonly string[] | undefined; | |
| 251 | + proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined; | |
| 252 | + entry: ThreadPlanCatalogEntry; | |
| 253 | + } | |
| 254 | + >(); | |
| 255 | + const nextResult: ThreadPlanCatalogEntry[] = []; | |
| 256 | + let changed = !sameThreadIds; | |
| 257 | + | |
| 258 | + for (const threadId of threadIds) { | |
| 259 | + let shell: object | undefined; | |
| 260 | + let proposedPlanIds: readonly string[] | undefined; | |
| 261 | + let proposedPlansById: Record<string, Thread["proposedPlans"][number]> | undefined; | |
| 262 | + | |
| 263 | + for (const environmentState of Object.values(state.environmentStateById)) { | |
| 264 | + const matchedShell = environmentState.threadShellById[threadId]; | |
| 265 | + if (!matchedShell) { | |
| 266 | + continue; | |
| 267 | + } | |
| 268 | + shell = matchedShell; | |
| 269 | + proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; | |
| 270 | + proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as | |
| 271 | + | Record<string, Thread["proposedPlans"][number]> | |
| 272 | + | undefined; | |
| 273 | + break; | |
| 274 | + } | |
| 216 | 275 | |
| 217 | − return useMemo( | |
| 218 | − () => | |
| 219 | − threadIds.flatMap((threadId) => { | |
| 220 | − if (!threadShellById[threadId]) { | |
| 221 | − return []; | |
| 276 | + if (!shell) { | |
| 277 | + const previous = previousEntries.get(threadId); | |
| 278 | + if ( | |
| 279 | + previous && | |
| 280 | + previous.shell === null && | |
| 281 | + previous.proposedPlanIds === undefined && | |
| 282 | + previous.proposedPlansById === undefined | |
| 283 | + ) { | |
| 284 | + nextEntries.set(threadId, previous); | |
| 285 | + continue; | |
| 286 | + } | |
| 287 | + changed = true; | |
| 288 | + nextEntries.set(threadId, { | |
| 289 | + shell: null, | |
| 290 | + proposedPlanIds: undefined, | |
| 291 | + proposedPlansById: undefined, | |
| 292 | + entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, | |
| 293 | + }); | |
| 294 | + continue; | |
| 295 | + } | |
| 296 | + | |
| 297 | + const previous = previousEntries.get(threadId); | |
| 298 | + if ( | |
| 299 | + previous && | |
| 300 | + previous.shell === shell && | |
| 301 | + previous.proposedPlanIds === proposedPlanIds && | |
| 302 | + previous.proposedPlansById === proposedPlansById | |
| 303 | + ) { | |
| 304 | + nextEntries.set(threadId, previous); | |
| 305 | + nextResult.push(previous.entry); | |
| 306 | + continue; | |
| 307 | + } | |
| 308 | + | |
| 309 | + changed = true; | |
| 310 | + const proposedPlans = | |
| 311 | + proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById | |
| 312 | + ? proposedPlanIds.flatMap((planId) => { | |
| 313 | + const proposedPlan = proposedPlansById?.[planId]; | |
| 314 | + return proposedPlan ? [proposedPlan] : []; | |
| 315 | + }) | |
| 316 | + : EMPTY_PROPOSED_PLANS; | |
| 317 | + const entry = { id: threadId, proposedPlans }; | |
| 318 | + nextEntries.set(threadId, { | |
| 319 | + shell, | |
| 320 | + proposedPlanIds, | |
| 321 | + proposedPlansById, | |
| 322 | + entry, | |
| 323 | + }); | |
| 324 | + nextResult.push(entry); | |
| 222 | 325 | } |
| 223 | 326 | |
| 224 | − const proposedPlans = | |
| 225 | − proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => { | |
| 226 | − const plan = proposedPlanByThreadId[threadId]?.[planId]; | |
| 227 | − return plan ? [plan] : []; | |
| 228 | − }) ?? []; | |
| 327 | + if (!changed && previousResult.length === nextResult.length) { | |
| 328 | + return previousResult; | |
| 329 | + } | |
| 229 | 330 | |
| 230 | − return [{ id: threadId, proposedPlans }]; | |
| 231 | − }), | |
| 232 | − [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById], | |
| 331 | + previousThreadIds = threadIds; | |
| 332 | + previousEntries = nextEntries; | |
| 333 | + previousResult = nextResult; | |
| 334 | + return nextResult; | |
| 335 | + }; | |
| 336 | + }, [threadIds]), | |
| 233 | 337 | ); |
| 234 | 338 | } |
| 235 | 339 | |
| @@ -278,9 +382,19 @@ const terminalContextIdListsEqual = ( · 42 unmodified lines | ||
| 278 | 382 | ): boolean => |
| 279 | 383 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); |
| 280 | 384 | |
| 281 | −interface ChatViewProps { | |
| 282 | − threadId: ThreadId; | |
| 283 | −} | |
| 385 | +type ChatViewProps = | |
| 386 | + | { | |
| 387 | + environmentId: EnvironmentId; | |
| 388 | + threadId: ThreadId; | |
| 389 | + routeKind: "server"; | |
| 390 | + draftId?: never; | |
| 391 | + } | |
| 392 | + | { | |
| 393 | + environmentId: EnvironmentId; | |
| 394 | + threadId: ThreadId; | |
| 395 | + routeKind: "draft"; | |
| 396 | + draftId: DraftId; | |
| 397 | + }; | |
| 284 | 398 | |
| 285 | 399 | interface TerminalLaunchContext { |
| 286 | 400 | threadId: ThreadId; |
| @@ -358,6 +472,7 @@ function useLocalDispatchState(input: { · 71 unmodified lines | ||
| 358 | 472 | } |
| 359 | 473 | |
| 360 | 474 | interface PersistentThreadTerminalDrawerProps { |
| 475 | + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; | |
| 361 | 476 | threadId: ThreadId; |
| 362 | 477 | visible: boolean; |
| 363 | 478 | launchContext: PersistentTerminalLaunchContext | null; |
| @@ -369,6 +484,7 @@ interface PersistentThreadTerminalDrawerProps { · 5 unmodified lines | ||
| 369 | 484 | } |
| 370 | 485 | |
| 371 | 486 | function PersistentThreadTerminalDrawer({ |
| 487 | + threadRef, | |
| 372 | 488 | threadId, |
| 373 | 489 | visible, |
| 374 | 490 | launchContext, |
| @@ -378,19 +494,16 @@ function PersistentThreadTerminalDrawer({ · 3 unmodified lines | ||
| 378 | 494 | closeShortcutLabel, |
| 379 | 495 | onAddTerminalContext, |
| 380 | 496 | }: PersistentThreadTerminalDrawerProps) { |
| 381 | − const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); | |
| 382 | − const draftThread = useComposerDraftStore( | |
| 383 | − (store) => store.draftThreadsByThreadId[threadId] ?? null, | |
| 384 | − ); | |
| 385 | − const project = useStore((state) => | |
| 386 | − serverThread?.projectId | |
| 387 | − ? state.projectById[serverThread.projectId] | |
| 388 | − : draftThread?.projectId | |
| 389 | − ? state.projectById[draftThread.projectId] | |
| 390 | − : undefined, | |
| 391 | − ); | |
| 497 | + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); | |
| 498 | + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); | |
| 499 | + const projectRef = serverThread | |
| 500 | + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) | |
| 501 | + : draftThread | |
| 502 | + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | |
| 503 | + : null; | |
| 504 | + const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); | |
| 392 | 505 | const terminalState = useTerminalStateStore((state) => |
| 393 | − selectThreadTerminalState(state.terminalStateByThreadId, threadId), | |
| 506 | + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), | |
| 394 | 507 | ); |
| 395 | 508 | const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); |
| 396 | 509 | const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); |
| @@ -436,32 +549,32 @@ function PersistentThreadTerminalDrawer({ · 39 unmodified lines | ||
| 436 | 549 | |
| 437 | 550 | const setTerminalHeight = useCallback( |
| 438 | 551 | (height: number) => { |
| 439 | − storeSetTerminalHeight(threadId, height); | |
| 552 | + storeSetTerminalHeight(threadRef, height); | |
| 440 | 553 | }, |
| 441 | − [storeSetTerminalHeight, threadId], | |
| 554 | + [storeSetTerminalHeight, threadRef], | |
| 442 | 555 | ); |
| 443 | 556 | |
| 444 | 557 | const splitTerminal = useCallback(() => { |
| 445 | − storeSplitTerminal(threadId, `terminal-${randomUUID()}`); | |
| 558 | + storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); | |
| 446 | 559 | bumpFocusRequestId(); |
| 447 | − }, [bumpFocusRequestId, storeSplitTerminal, threadId]); | |
| 560 | + }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); | |
| 448 | 561 | |
| 449 | 562 | const createNewTerminal = useCallback(() => { |
| 450 | − storeNewTerminal(threadId, `terminal-${randomUUID()}`); | |
| 563 | + storeNewTerminal(threadRef, `terminal-${randomUUID()}`); | |
| 451 | 564 | bumpFocusRequestId(); |
| 452 | − }, [bumpFocusRequestId, storeNewTerminal, threadId]); | |
| 565 | + }, [bumpFocusRequestId, storeNewTerminal, threadRef]); | |
| 453 | 566 | |
| 454 | 567 | const activateTerminal = useCallback( |
| 455 | 568 | (terminalId: string) => { |
| 456 | − storeSetActiveTerminal(threadId, terminalId); | |
| 569 | + storeSetActiveTerminal(threadRef, terminalId); | |
| 457 | 570 | bumpFocusRequestId(); |
| 458 | 571 | }, |
| 459 | − [bumpFocusRequestId, storeSetActiveTerminal, threadId], | |
| 572 | + [bumpFocusRequestId, storeSetActiveTerminal, threadRef], | |
| 460 | 573 | ); |
| 461 | 574 | |
| 462 | 575 | const closeTerminal = useCallback( |
| 463 | 576 | (terminalId: string) => { |
| 464 | − const api = readNativeApi(); | |
| 577 | + const api = readEnvironmentApi(threadRef.environmentId); | |
| 465 | 578 | if (!api) return; |
| 466 | 579 | const isFinalTerminal = terminalState.terminalIds.length <= 1; |
| 467 | 580 | const fallbackExitWrite = () => |
| @@ -482,10 +595,10 @@ function PersistentThreadTerminalDrawer({ · 14 unmodified lines | ||
| 482 | 595 | void fallbackExitWrite(); |
| 483 | 596 | } |
| 484 | 597 | |
| 485 | − storeCloseTerminal(threadId, terminalId); | |
| 598 | + storeCloseTerminal(threadRef, terminalId); | |
| 486 | 599 | bumpFocusRequestId(); |
| 487 | 600 | }, |
| 488 | − [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId], | |
| 601 | + [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef], | |
| 489 | 602 | ); |
| 490 | 603 | |
| 491 | 604 | const handleAddTerminalContext = useCallback( |
| @@ -505,6 +618,7 @@ function PersistentThreadTerminalDrawer({ · 13 unmodified lines | ||
| 505 | 618 | return ( |
| 506 | 619 | <div className={visible ? undefined : "hidden"}> |
| 507 | 620 | <ThreadTerminalDrawer |
| 621 | + threadRef={threadRef} | |
| 508 | 622 | threadId={threadId} |
| 509 | 623 | cwd={cwd} |
| 510 | 624 | worktreePath={effectiveWorktreePath} |
| @@ -530,12 +644,27 @@ function PersistentThreadTerminalDrawer({ · 19 unmodified lines | ||
| 530 | 644 | ); |
| 531 | 645 | } |
| 532 | 646 | |
| 533 | −export default function ChatView({ threadId }: ChatViewProps) { | |
| 534 | − const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); | |
| 647 | +export default function ChatView(props: ChatViewProps) { | |
| 648 | + const { environmentId, threadId, routeKind } = props; | |
| 649 | + const draftId = routeKind === "draft" ? props.draftId : null; | |
| 650 | + const routeThreadRef = useMemo( | |
| 651 | + () => scopeThreadRef(environmentId, threadId), | |
| 652 | + [environmentId, threadId], | |
| 653 | + ); | |
| 654 | + const composerDraftTarget: ScopedThreadRef | DraftId = | |
| 655 | + routeKind === "server" ? routeThreadRef : props.draftId; | |
| 656 | + const serverThread = useStore( | |
| 657 | + useMemo( | |
| 658 | + () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null), | |
| 659 | + [routeKind, routeThreadRef], | |
| 660 | + ), | |
| 661 | + ); | |
| 535 | 662 | const setStoreThreadError = useStore((store) => store.setError); |
| 536 | 663 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); |
| 537 | − const activeThreadLastVisitedAt = useUiStateStore( | |
| 538 | − (store) => store.threadLastVisitedAtById[threadId], | |
| 664 | + const activeThreadLastVisitedAt = useUiStateStore((store) => | |
| 665 | + routeKind === "server" | |
| 666 | + ? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))] | |
| 667 | + : undefined, | |
| 539 | 668 | ); |
| 540 | 669 | const settings = useSettings(); |
| 541 | 670 | const setStickyComposerModelSelection = useComposerDraftStore( |
| @@ -548,7 +677,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines | ||
| 548 | 677 | select: (params) => parseDiffRouteSearch(params), |
| 549 | 678 | }); |
| 550 | 679 | const { resolvedTheme } = useTheme(); |
| 551 | − const composerDraft = useComposerThreadDraft(threadId); | |
| 680 | + const composerDraft = useComposerThreadDraft(composerDraftTarget); | |
| 552 | 681 | const prompt = composerDraft.prompt; |
| 553 | 682 | const composerImages = composerDraft.images; |
| 554 | 683 | const composerTerminalContexts = composerDraft.terminalContexts; |
| @@ -591,16 +720,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 36 unmodified lines | ||
| 591 | 720 | ); |
| 592 | 721 | const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); |
| 593 | 722 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 594 | − const getDraftThreadByProjectId = useComposerDraftStore( | |
| 595 | − (store) => store.getDraftThreadByProjectId, | |
| 723 | + const getDraftSessionByLogicalProjectKey = useComposerDraftStore( | |
| 724 | + (store) => store.getDraftSessionByLogicalProjectKey, | |
| 596 | 725 | ); |
| 597 | − const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); | |
| 598 | − const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); | |
| 599 | − const clearProjectDraftThreadId = useComposerDraftStore( | |
| 600 | − (store) => store.clearProjectDraftThreadId, | |
| 726 | + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); | |
| 727 | + const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); | |
| 728 | + const setLogicalProjectDraftThreadId = useComposerDraftStore( | |
| 729 | + (store) => store.setLogicalProjectDraftThreadId, | |
| 601 | 730 | ); |
| 602 | − const draftThread = useComposerDraftStore( | |
| 603 | − (store) => store.draftThreadsByThreadId[threadId] ?? null, | |
| 731 | + const draftThread = useComposerDraftStore((store) => | |
| 732 | + routeKind === "server" | |
| 733 | + ? store.getDraftSessionByRef(routeThreadRef) | |
| 734 | + : draftId | |
| 735 | + ? store.getDraftSession(draftId) | |
| 736 | + : null, | |
| 604 | 737 | ); |
| 605 | 738 | const promptRef = useRef(prompt); |
| 606 | 739 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); |
| @@ -610,8 +743,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 3 unmodified lines | ||
| 610 | 743 | const optimisticUserMessagesRef = useRef(optimisticUserMessages); |
| 611 | 744 | optimisticUserMessagesRef.current = optimisticUserMessages; |
| 612 | 745 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts); |
| 613 | − const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< | |
| 614 | − Record<ThreadId, string | null> | |
| 746 | + const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< | |
| 747 | + Record<string, string | null> | |
| 615 | 748 | >({}); |
| 616 | 749 | const [isConnecting, _setIsConnecting] = useState(false); |
| 617 | 750 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| @@ -689,66 +822,73 @@ export default function ChatView({ threadId }: ChatViewProps) { · 71 unmodified lines | ||
| 689 | 822 | setMessagesScrollElement(element); |
| 690 | 823 | }, []); |
| 691 | 824 | |
| 692 | − const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); | |
| 693 | − const terminalState = useMemo( | |
| 694 | − () => selectThreadTerminalState(terminalStateByThreadId, threadId), | |
| 695 | − [terminalStateByThreadId, threadId], | |
| 825 | + const terminalState = useTerminalStateStore((state) => | |
| 826 | + selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), | |
| 696 | 827 | ); |
| 697 | − const openTerminalThreadIds = useMemo( | |
| 698 | − () => | |
| 699 | − Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) => | |
| 700 | − nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [], | |
| 828 | + const openTerminalThreadKeys = useTerminalStateStore( | |
| 829 | + useShallow((state) => | |
| 830 | + Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => | |
| 831 | + nextTerminalState.terminalOpen ? [nextThreadKey] : [], | |
| 701 | 832 | ), |
| 702 | − [terminalStateByThreadId], | |
| 833 | + ), | |
| 703 | 834 | ); |
| 704 | 835 | const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); |
| 705 | 836 | const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); |
| 706 | 837 | const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); |
| 707 | 838 | const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); |
| 708 | 839 | const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); |
| 709 | − const serverThreadIds = useStore((state) => state.threadIds); | |
| 840 | + const serverThreadKeys = useStore( | |
| 841 | + useShallow((state) => | |
| 842 | + selectThreadsAcrossEnvironments(state).map((thread) => | |
| 843 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 844 | + ), | |
| 845 | + ), | |
| 846 | + ); | |
| 710 | 847 | const storeServerTerminalLaunchContext = useTerminalStateStore( |
| 711 | − (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, | |
| 848 | + (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null, | |
| 712 | 849 | ); |
| 713 | 850 | const storeClearTerminalLaunchContext = useTerminalStateStore( |
| 714 | 851 | (s) => s.clearTerminalLaunchContext, |
| 715 | 852 | ); |
| 716 | − const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); | |
| 717 | − const draftThreadIds = useMemo( | |
| 718 | − () => Object.keys(draftThreadsByThreadId) as ThreadId[], | |
| 719 | − [draftThreadsByThreadId], | |
| 853 | + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); | |
| 854 | + const draftThreadKeys = useMemo( | |
| 855 | + () => | |
| 856 | + Object.values(draftThreadsByThreadKey).map((draftThread) => | |
| 857 | + scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), | |
| 858 | + ), | |
| 859 | + [draftThreadsByThreadKey], | |
| 720 | 860 | ); |
| 721 | − const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState<ThreadId[]>([]); | |
| 861 | + const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState<string[]>([]); | |
| 722 | 862 | |
| 723 | 863 | const setPrompt = useCallback( |
| 724 | 864 | (nextPrompt: string) => { |
| 725 | − setComposerDraftPrompt(threadId, nextPrompt); | |
| 865 | + setComposerDraftPrompt(composerDraftTarget, nextPrompt); | |
| 726 | 866 | }, |
| 727 | − [setComposerDraftPrompt, threadId], | |
| 867 | + [composerDraftTarget, setComposerDraftPrompt], | |
| 728 | 868 | ); |
| 729 | 869 | const addComposerImage = useCallback( |
| 730 | 870 | (image: ComposerImageAttachment) => { |
| 731 | − addComposerDraftImage(threadId, image); | |
| 871 | + addComposerDraftImage(composerDraftTarget, image); | |
| 732 | 872 | }, |
| 733 | − [addComposerDraftImage, threadId], | |
| 873 | + [addComposerDraftImage, composerDraftTarget], | |
| 734 | 874 | ); |
| 735 | 875 | const addComposerImagesToDraft = useCallback( |
| 736 | 876 | (images: ComposerImageAttachment[]) => { |
| 737 | − addComposerDraftImages(threadId, images); | |
| 877 | + addComposerDraftImages(composerDraftTarget, images); | |
| 738 | 878 | }, |
| 739 | − [addComposerDraftImages, threadId], | |
| 879 | + [addComposerDraftImages, composerDraftTarget], | |
| 740 | 880 | ); |
| 741 | 881 | const addComposerTerminalContextsToDraft = useCallback( |
| 742 | 882 | (contexts: TerminalContextDraft[]) => { |
| 743 | − addComposerDraftTerminalContexts(threadId, contexts); | |
| 883 | + addComposerDraftTerminalContexts(composerDraftTarget, contexts); | |
| 744 | 884 | }, |
| 745 | − [addComposerDraftTerminalContexts, threadId], | |
| 885 | + [addComposerDraftTerminalContexts, composerDraftTarget], | |
| 746 | 886 | ); |
| 747 | 887 | const removeComposerImageFromDraft = useCallback( |
| 748 | 888 | (imageId: string) => { |
| 749 | − removeComposerDraftImage(threadId, imageId); | |
| 889 | + removeComposerDraftImage(composerDraftTarget, imageId); | |
| 750 | 890 | }, |
| 751 | − [removeComposerDraftImage, threadId], | |
| 891 | + [composerDraftTarget, removeComposerDraftImage], | |
| 752 | 892 | ); |
| 753 | 893 | const removeComposerTerminalContextFromDraft = useCallback( |
| 754 | 894 | (contextId: string) => { |
| @@ -761,7 +901,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines | ||
| 761 | 901 | const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); |
| 762 | 902 | promptRef.current = nextPrompt.prompt; |
| 763 | 903 | setPrompt(nextPrompt.prompt); |
| 764 | − removeComposerDraftTerminalContext(threadId, contextId); | |
| 904 | + removeComposerDraftTerminalContext(composerDraftTarget, contextId); | |
| 765 | 905 | setComposerCursor(nextPrompt.cursor); |
| 766 | 906 | setComposerTrigger( |
| 767 | 907 | detectComposerTrigger( |
| @@ -770,13 +910,19 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines | ||
| 770 | 910 | ), |
| 771 | 911 | ); |
| 772 | 912 | }, |
| 773 | − [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], | |
| 913 | + [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt], | |
| 774 | 914 | ); |
| 775 | 915 | |
| 776 | − const fallbackDraftProject = useStore((state) => | |
| 777 | − draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined, | |
| 916 | + const fallbackDraftProjectRef = draftThread | |
| 917 | + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | |
| 918 | + : null; | |
| 919 | + const fallbackDraftProject = useStore( | |
| 920 | + useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), | |
| 778 | 921 | ); |
| 779 | − const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); | |
| 922 | + const localDraftError = | |
| 923 | + routeKind === "server" && serverThread | |
| 924 | + ? null | |
| 925 | + : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); | |
| 780 | 926 | const localDraftThread = useMemo( |
| 781 | 927 | () => |
| 782 | 928 | draftThread |
| @@ -792,20 +938,25 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines | ||
| 792 | 938 | : undefined, |
| 793 | 939 | [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], |
| 794 | 940 | ); |
| 795 | − const activeThread = serverThread ?? localDraftThread; | |
| 941 | + const isServerThread = routeKind === "server" && serverThread !== undefined; | |
| 942 | + const activeThread = isServerThread ? serverThread : localDraftThread; | |
| 796 | 943 | const runtimeMode = |
| 797 | 944 | composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 798 | 945 | const interactionMode = |
| 799 | 946 | composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; |
| 800 | − const isServerThread = serverThread !== undefined; | |
| 801 | 947 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 802 | 948 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 803 | 949 | const diffOpen = rawSearch.diff === "1"; |
| 804 | 950 | const activeThreadId = activeThread?.id ?? null; |
| 805 | − const existingOpenTerminalThreadIds = useMemo(() => { | |
| 806 | − const existingThreadIds = new Set<ThreadId>([...serverThreadIds, ...draftThreadIds]); | |
| 807 | − return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId)); | |
| 808 | − }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]); | |
| 951 | + const activeThreadRef = useMemo( | |
| 952 | + () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), | |
| 953 | + [activeThread], | |
| 954 | + ); | |
| 955 | + const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; | |
| 956 | + const existingOpenTerminalThreadKeys = useMemo(() => { | |
| 957 | + const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); | |
| 958 | + return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); | |
| 959 | + }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); | |
| 809 | 960 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 810 | 961 | const threadPlanCatalog = useThreadPlanCatalog( |
| 811 | 962 | useMemo(() => { |
| @@ -825,12 +976,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 13 unmodified lines | ||
| 825 | 976 | [activeThread?.activities], |
| 826 | 977 | ); |
| 827 | 978 | useEffect(() => { |
| 828 | − setMountedTerminalThreadIds((currentThreadIds) => { | |
| 979 | + setMountedTerminalThreadKeys((currentThreadIds) => { | |
| 829 | 980 | const nextThreadIds = reconcileMountedTerminalThreadIds({ |
| 830 | 981 | currentThreadIds, |
| 831 | − openThreadIds: existingOpenTerminalThreadIds, | |
| 832 | − activeThreadId, | |
| 833 | − activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen), | |
| 982 | + openThreadIds: existingOpenTerminalThreadKeys, | |
| 983 | + activeThreadId: activeThreadKey, | |
| 984 | + activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen), | |
| 834 | 985 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 835 | 986 | }); |
| 836 | 987 | return currentThreadIds.length === nextThreadIds.length && |
| @@ -838,10 +989,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line | ||
| 838 | 989 | ? currentThreadIds |
| 839 | 990 | : nextThreadIds; |
| 840 | 991 | }); |
| 841 | − }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); | |
| 992 | + }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); | |
| 842 | 993 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 843 | − const activeProject = useStore((state) => | |
| 844 | − activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined, | |
| 994 | + const activeProjectRef = activeThread | |
| 995 | + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) | |
| 996 | + : null; | |
| 997 | + const activeProject = useStore( | |
| 998 | + useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), | |
| 845 | 999 | ); |
| 846 | 1000 | |
| 847 | 1001 | const openPullRequestDialog = useCallback( |
| @@ -867,50 +1021,71 @@ export default function ChatView({ threadId }: ChatViewProps) { · 19 unmodified lines | ||
| 867 | 1021 | if (!activeProject) { |
| 868 | 1022 | throw new Error("No active project is available for this pull request."); |
| 869 | 1023 | } |
| 870 | − const storedDraftThread = getDraftThreadByProjectId(activeProject.id); | |
| 871 | − if (storedDraftThread) { | |
| 872 | − setDraftThreadContext(storedDraftThread.threadId, input); | |
| 873 | − setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); | |
| 874 | − if (storedDraftThread.threadId !== threadId) { | |
| 1024 | + const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); | |
| 1025 | + const logicalProjectKey = deriveLogicalProjectKey(activeProject); | |
| 1026 | + const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); | |
| 1027 | + if (storedDraftSession) { | |
| 1028 | + setDraftThreadContext(storedDraftSession.draftId, input); | |
| 1029 | + setLogicalProjectDraftThreadId( | |
| 1030 | + logicalProjectKey, | |
| 1031 | + activeProjectRef, | |
| 1032 | + storedDraftSession.draftId, | |
| 1033 | + { | |
| 1034 | + threadId: storedDraftSession.threadId, | |
| 1035 | + ...input, | |
| 1036 | + }, | |
| 1037 | + ); | |
| 1038 | + if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { | |
| 875 | 1039 | await navigate({ |
| 876 | − to: "/$threadId", | |
| 877 | − params: { threadId: storedDraftThread.threadId }, | |
| 1040 | + to: "/draft/$draftId", | |
| 1041 | + params: buildDraftThreadRouteParams(storedDraftSession.draftId), | |
| 878 | 1042 | }); |
| 879 | 1043 | } |
| 880 | − return storedDraftThread.threadId; | |
| 1044 | + return storedDraftSession.threadId; | |
| 881 | 1045 | } |
| 882 | 1046 | |
| 883 | − const activeDraftThread = getDraftThread(threadId); | |
| 884 | − if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { | |
| 885 | − setDraftThreadContext(threadId, input); | |
| 886 | − setProjectDraftThreadId(activeProject.id, threadId, input); | |
| 887 | − return threadId; | |
| 1047 | + const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; | |
| 1048 | + if ( | |
| 1049 | + !isServerThread && | |
| 1050 | + activeDraftSession?.logicalProjectKey === logicalProjectKey && | |
| 1051 | + draftId | |
| 1052 | + ) { | |
| 1053 | + setDraftThreadContext(draftId, input); | |
| 1054 | + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { | |
| 1055 | + threadId: activeDraftSession.threadId, | |
| 1056 | + createdAt: activeDraftSession.createdAt, | |
| 1057 | + runtimeMode: activeDraftSession.runtimeMode, | |
| 1058 | + interactionMode: activeDraftSession.interactionMode, | |
| 1059 | + ...input, | |
| 1060 | + }); | |
| 1061 | + return activeDraftSession.threadId; | |
| 888 | 1062 | } |
| 889 | 1063 | |
| 890 | − clearProjectDraftThreadId(activeProject.id); | |
| 1064 | + const nextDraftId = newDraftId(); | |
| 891 | 1065 | const nextThreadId = newThreadId(); |
| 892 | − setProjectDraftThreadId(activeProject.id, nextThreadId, { | |
| 1066 | + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { | |
| 1067 | + threadId: nextThreadId, | |
| 893 | 1068 | createdAt: new Date().toISOString(), |
| 894 | 1069 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 895 | 1070 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 896 | 1071 | ...input, |
| 897 | 1072 | }); |
| 898 | 1073 | await navigate({ |
| 899 | − to: "/$threadId", | |
| 900 | − params: { threadId: nextThreadId }, | |
| 1074 | + to: "/draft/$draftId", | |
| 1075 | + params: buildDraftThreadRouteParams(nextDraftId), | |
| 901 | 1076 | }); |
| 902 | 1077 | return nextThreadId; |
| 903 | 1078 | }, |
| 904 | 1079 | [ |
| 905 | 1080 | activeProject, |
| 906 | − clearProjectDraftThreadId, | |
| 907 | − getDraftThread, | |
| 908 | − getDraftThreadByProjectId, | |
| 1081 | + draftId, | |
| 1082 | + getDraftSession, | |
| 1083 | + getDraftSessionByLogicalProjectKey, | |
| 909 | 1084 | isServerThread, |
| 910 | 1085 | navigate, |
| 1086 | + routeKind, | |
| 911 | 1087 | setDraftThreadContext, |
| 912 | − setProjectDraftThreadId, | |
| 913 | − threadId, | |
| 1088 | + setLogicalProjectDraftThreadId, | |
| 914 | 1089 | ], |
| 915 | 1090 | ); |
| 916 | 1091 | |
| @@ -934,12 +1109,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 17 unmodified lines | ||
| 934 | 1109 | const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; |
| 935 | 1110 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; |
| 936 | 1111 | |
| 937 | − markThreadVisited(serverThread.id); | |
| 1112 | + markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id))); | |
| 938 | 1113 | }, [ |
| 939 | 1114 | activeLatestTurn?.completedAt, |
| 940 | 1115 | activeThreadLastVisitedAt, |
| 941 | 1116 | latestTurnSettled, |
| 942 | 1117 | markThreadVisited, |
| 1118 | + serverThread?.environmentId, | |
| 943 | 1119 | serverThread?.id, |
| 944 | 1120 | ]); |
| 945 | 1121 | |
| @@ -959,7 +1135,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 13 unmodified lines | ||
| 959 | 1135 | ); |
| 960 | 1136 | const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; |
| 961 | 1137 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 962 | − threadId, | |
| 1138 | + threadRef: composerDraftTarget, | |
| 963 | 1139 | providers: providerStatuses, |
| 964 | 1140 | selectedProvider, |
| 965 | 1141 | threadModelSelection: activeThread?.modelSelection, |
| @@ -1358,7 +1534,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 392 unmodified lines | ||
| 1358 | 1534 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 1359 | 1535 | ); |
| 1360 | 1536 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 1361 | − const gitStatusQuery = useGitStatus(gitCwd); | |
| 1537 | + const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); | |
| 1362 | 1538 | const keybindings = useServerKeybindings(); |
| 1363 | 1539 | const availableEditors = useServerAvailableEditors(); |
| 1364 | 1540 | const modelOptionsByProvider = useMemo( |
| @@ -1394,6 +1570,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 29 unmodified lines | ||
| 1394 | 1570 | ); |
| 1395 | 1571 | const workspaceEntriesQuery = useQuery( |
| 1396 | 1572 | projectSearchEntriesQueryOptions({ |
| 1573 | + environmentId, | |
| 1397 | 1574 | cwd: gitCwd, |
| 1398 | 1575 | query: effectivePathQuery, |
| 1399 | 1576 | enabled: isPathTrigger, |
| @@ -1531,16 +1708,22 @@ export default function ChatView({ threadId }: ChatViewProps) { · 131 unmodified lines | ||
| 1531 | 1708 | [keybindings, nonTerminalShortcutLabelOptions], |
| 1532 | 1709 | ); |
| 1533 | 1710 | const onToggleDiff = useCallback(() => { |
| 1711 | + if (!isServerThread) { | |
| 1712 | + return; | |
| 1713 | + } | |
| 1534 | 1714 | void navigate({ |
| 1535 | − to: "/$threadId", | |
| 1536 | − params: { threadId }, | |
| 1715 | + to: "/$environmentId/$threadId", | |
| 1716 | + params: { | |
| 1717 | + environmentId, | |
| 1718 | + threadId, | |
| 1719 | + }, | |
| 1537 | 1720 | replace: true, |
| 1538 | 1721 | search: (previous) => { |
| 1539 | 1722 | const rest = stripDiffSearchParams(previous); |
| 1540 | 1723 | return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; |
| 1541 | 1724 | }, |
| 1542 | 1725 | }); |
| 1543 | − }, [diffOpen, navigate, threadId]); | |
| 1726 | + }, [diffOpen, environmentId, isServerThread, navigate, threadId]); | |
| 1544 | 1727 | |
| 1545 | 1728 | const envLocked = Boolean( |
| 1546 | 1729 | activeThread && |
| @@ -1561,21 +1744,27 @@ export default function ChatView({ threadId }: ChatViewProps) { · 14 unmodified lines | ||
| 1561 | 1744 | (targetThreadId: ThreadId | null, error: string | null) => { |
| 1562 | 1745 | if (!targetThreadId) return; |
| 1563 | 1746 | const nextError = sanitizeThreadErrorMessage(error); |
| 1564 | − if (useStore.getState().threadShellById[targetThreadId] !== undefined) { | |
| 1747 | + const isCurrentServerThread = shouldWriteThreadErrorToCurrentServerThread({ | |
| 1748 | + serverThread, | |
| 1749 | + routeThreadRef, | |
| 1750 | + targetThreadId, | |
| 1751 | + }); | |
| 1752 | + if (isCurrentServerThread) { | |
| 1565 | 1753 | setStoreThreadError(targetThreadId, nextError); |
| 1566 | 1754 | return; |
| 1567 | 1755 | } |
| 1568 | − setLocalDraftErrorsByThreadId((existing) => { | |
| 1569 | − if ((existing[targetThreadId] ?? null) === nextError) { | |
| 1756 | + const localDraftErrorKey = draftId ?? targetThreadId; | |
| 1757 | + setLocalDraftErrorsByDraftId((existing) => { | |
| 1758 | + if ((existing[localDraftErrorKey] ?? null) === nextError) { | |
| 1570 | 1759 | return existing; |
| 1571 | 1760 | } |
| 1572 | 1761 | return { |
| 1573 | 1762 | ...existing, |
| 1574 | − [targetThreadId]: nextError, | |
| 1763 | + [localDraftErrorKey]: nextError, | |
| 1575 | 1764 | }; |
| 1576 | 1765 | }); |
| 1577 | 1766 | }, |
| 1578 | − [setStoreThreadError], | |
| 1767 | + [draftId, routeThreadRef, serverThread, setStoreThreadError], | |
| 1579 | 1768 | ); |
| 1580 | 1769 | |
| 1581 | 1770 | const focusComposer = useCallback(() => { |
| @@ -1606,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 24 unmodified lines | ||
| 1606 | 1795 | insertion.cursor, |
| 1607 | 1796 | ); |
| 1608 | 1797 | const inserted = insertComposerDraftTerminalContext( |
| 1609 | − activeThread.id, | |
| 1798 | + scopeThreadRef(activeThread.environmentId, activeThread.id), | |
| 1610 | 1799 | insertion.prompt, |
| 1611 | 1800 | { |
| 1612 | 1801 | id: randomUUID(), |
| @@ -1630,30 +1819,30 @@ export default function ChatView({ threadId }: ChatViewProps) { · 17 unmodified lines | ||
| 1630 | 1819 | ); |
| 1631 | 1820 | const setTerminalOpen = useCallback( |
| 1632 | 1821 | (open: boolean) => { |
| 1633 | − if (!activeThreadId) return; | |
| 1634 | − storeSetTerminalOpen(activeThreadId, open); | |
| 1822 | + if (!activeThreadRef) return; | |
| 1823 | + storeSetTerminalOpen(activeThreadRef, open); | |
| 1635 | 1824 | }, |
| 1636 | − [activeThreadId, storeSetTerminalOpen], | |
| 1825 | + [activeThreadRef, storeSetTerminalOpen], | |
| 1637 | 1826 | ); |
| 1638 | 1827 | const toggleTerminalVisibility = useCallback(() => { |
| 1639 | − if (!activeThreadId) return; | |
| 1828 | + if (!activeThreadRef) return; | |
| 1640 | 1829 | setTerminalOpen(!terminalState.terminalOpen); |
| 1641 | − }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); | |
| 1830 | + }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); | |
| 1642 | 1831 | const splitTerminal = useCallback(() => { |
| 1643 | − if (!activeThreadId || hasReachedSplitLimit) return; | |
| 1832 | + if (!activeThreadRef || hasReachedSplitLimit) return; | |
| 1644 | 1833 | const terminalId = `terminal-${randomUUID()}`; |
| 1645 | − storeSplitTerminal(activeThreadId, terminalId); | |
| 1834 | + storeSplitTerminal(activeThreadRef, terminalId); | |
| 1646 | 1835 | setTerminalFocusRequestId((value) => value + 1); |
| 1647 | − }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); | |
| 1836 | + }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); | |
| 1648 | 1837 | const createNewTerminal = useCallback(() => { |
| 1649 | − if (!activeThreadId) return; | |
| 1838 | + if (!activeThreadRef) return; | |
| 1650 | 1839 | const terminalId = `terminal-${randomUUID()}`; |
| 1651 | − storeNewTerminal(activeThreadId, terminalId); | |
| 1840 | + storeNewTerminal(activeThreadRef, terminalId); | |
| 1652 | 1841 | setTerminalFocusRequestId((value) => value + 1); |
| 1653 | − }, [activeThreadId, storeNewTerminal]); | |
| 1842 | + }, [activeThreadRef, storeNewTerminal]); | |
| 1654 | 1843 | const closeTerminal = useCallback( |
| 1655 | 1844 | (terminalId: string) => { |
| 1656 | − const api = readNativeApi(); | |
| 1845 | + const api = readEnvironmentApi(environmentId); | |
| 1657 | 1846 | if (!activeThreadId || !api) return; |
| 1658 | 1847 | const isFinalTerminal = terminalState.terminalIds.length <= 1; |
| 1659 | 1848 | const fallbackExitWrite = () => |
| @@ -1676,10 +1865,18 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines | ||
| 1676 | 1865 | } else { |
| 1677 | 1866 | void fallbackExitWrite(); |
| 1678 | 1867 | } |
| 1679 | − storeCloseTerminal(activeThreadId, terminalId); | |
| 1868 | + if (activeThreadRef) { | |
| 1869 | + storeCloseTerminal(activeThreadRef, terminalId); | |
| 1870 | + } | |
| 1680 | 1871 | setTerminalFocusRequestId((value) => value + 1); |
| 1681 | 1872 | }, |
| 1682 | − [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length], | |
| 1873 | + [ | |
| 1874 | + activeThreadId, | |
| 1875 | + activeThreadRef, | |
| 1876 | + environmentId, | |
| 1877 | + storeCloseTerminal, | |
| 1878 | + terminalState.terminalIds.length, | |
| 1879 | + ], | |
| 1683 | 1880 | ); |
| 1684 | 1881 | const runProjectScript = useCallback( |
| 1685 | 1882 | async ( |
| @@ -1692,7 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines | ||
| 1692 | 1889 | rememberAsLastInvoked?: boolean; |
| 1693 | 1890 | }, |
| 1694 | 1891 | ) => { |
| 1695 | − const api = readNativeApi(); | |
| 1892 | + const api = readEnvironmentApi(environmentId); | |
| 1696 | 1893 | if (!api || !activeThreadId || !activeProject || !activeThread) return; |
| 1697 | 1894 | if (options?.rememberAsLastInvoked !== false) { |
| 1698 | 1895 | setLastInvokedScriptByProjectId((current) => { |
| @@ -1719,10 +1916,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 20 unmodified lines | ||
| 1719 | 1916 | worktreePath: targetWorktreePath, |
| 1720 | 1917 | }); |
| 1721 | 1918 | setTerminalOpen(true); |
| 1919 | + if (!activeThreadRef) { | |
| 1920 | + return; | |
| 1921 | + } | |
| 1722 | 1922 | if (shouldCreateNewTerminal) { |
| 1723 | − storeNewTerminal(activeThreadId, targetTerminalId); | |
| 1923 | + storeNewTerminal(activeThreadRef, targetTerminalId); | |
| 1724 | 1924 | } else { |
| 1725 | − storeSetActiveTerminal(activeThreadId, targetTerminalId); | |
| 1925 | + storeSetActiveTerminal(activeThreadRef, targetTerminalId); | |
| 1726 | 1926 | } |
| 1727 | 1927 | setTerminalFocusRequestId((value) => value + 1); |
| 1728 | 1928 | |
| @@ -1769,12 +1969,14 @@ export default function ChatView({ threadId }: ChatViewProps) { · 40 unmodified lines | ||
| 1769 | 1969 | activeProject, |
| 1770 | 1970 | activeThread, |
| 1771 | 1971 | activeThreadId, |
| 1972 | + activeThreadRef, | |
| 1772 | 1973 | gitCwd, |
| 1773 | 1974 | setTerminalOpen, |
| 1774 | 1975 | setThreadError, |
| 1775 | 1976 | storeNewTerminal, |
| 1776 | 1977 | storeSetActiveTerminal, |
| 1777 | 1978 | setLastInvokedScriptByProjectId, |
| 1979 | + environmentId, | |
| 1778 | 1980 | terminalState.activeTerminalId, |
| 1779 | 1981 | terminalState.runningTerminalIds, |
| 1780 | 1982 | terminalState.terminalIds, |
| @@ -1790,7 +1992,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines | ||
| 1790 | 1992 | keybinding?: string | null; |
| 1791 | 1993 | keybindingCommand: KeybindingCommand; |
| 1792 | 1994 | }) => { |
| 1793 | − const api = readNativeApi(); | |
| 1995 | + const api = readEnvironmentApi(environmentId); | |
| 1794 | 1996 | if (!api) return; |
| 1795 | 1997 | |
| 1796 | 1998 | await api.orchestration.dispatchCommand({ |
| @@ -1806,10 +2008,14 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines | ||
| 1806 | 2008 | }); |
| 1807 | 2009 | |
| 1808 | 2010 | if (isElectron && keybindingRule) { |
| 1809 | − await api.server.upsertKeybinding(keybindingRule); | |
| 2011 | + const localApi = readLocalApi(); | |
| 2012 | + if (!localApi) { | |
| 2013 | + throw new Error("Local API unavailable."); | |
| 2014 | + } | |
| 2015 | + await localApi.server.upsertKeybinding(keybindingRule); | |
| 1810 | 2016 | } |
| 1811 | 2017 | }, |
| 1812 | − [], | |
| 2018 | + [environmentId], | |
| 1813 | 2019 | ); |
| 1814 | 2020 | const saveProjectScript = useCallback( |
| 1815 | 2021 | async (input: NewProjectScriptInput) => { |
| @@ -1913,9 +2119,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 97 unmodified lines | ||
| 1913 | 2119 | const handleRuntimeModeChange = useCallback( |
| 1914 | 2120 | (mode: RuntimeMode) => { |
| 1915 | 2121 | if (mode === runtimeMode) return; |
| 1916 | − setComposerDraftRuntimeMode(threadId, mode); | |
| 2122 | + setComposerDraftRuntimeMode(composerDraftTarget, mode); | |
| 1917 | 2123 | if (isLocalDraftThread) { |
| 1918 | − setDraftThreadContext(threadId, { runtimeMode: mode }); | |
| 2124 | + setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); | |
| 1919 | 2125 | } |
| 1920 | 2126 | scheduleComposerFocus(); |
| 1921 | 2127 | }, |
| @@ -1923,18 +2129,18 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line | ||
| 1923 | 2129 | isLocalDraftThread, |
| 1924 | 2130 | runtimeMode, |
| 1925 | 2131 | scheduleComposerFocus, |
| 2132 | + composerDraftTarget, | |
| 1926 | 2133 | setComposerDraftRuntimeMode, |
| 1927 | 2134 | setDraftThreadContext, |
| 1928 | − threadId, | |
| 1929 | 2135 | ], |
| 1930 | 2136 | ); |
| 1931 | 2137 | |
| 1932 | 2138 | const handleInteractionModeChange = useCallback( |
| 1933 | 2139 | (mode: ProviderInteractionMode) => { |
| 1934 | 2140 | if (mode === interactionMode) return; |
| 1935 | − setComposerDraftInteractionMode(threadId, mode); | |
| 2141 | + setComposerDraftInteractionMode(composerDraftTarget, mode); | |
| 1936 | 2142 | if (isLocalDraftThread) { |
| 1937 | − setDraftThreadContext(threadId, { interactionMode: mode }); | |
| 2143 | + setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); | |
| 1938 | 2144 | } |
| 1939 | 2145 | scheduleComposerFocus(); |
| 1940 | 2146 | }, |
| @@ -1942,9 +2148,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line | ||
| 1942 | 2148 | interactionMode, |
| 1943 | 2149 | isLocalDraftThread, |
| 1944 | 2150 | scheduleComposerFocus, |
| 2151 | + composerDraftTarget, | |
| 1945 | 2152 | setComposerDraftInteractionMode, |
| 1946 | 2153 | setDraftThreadContext, |
| 1947 | − threadId, | |
| 1948 | 2154 | ], |
| 1949 | 2155 | ); |
| 1950 | 2156 | const toggleInteractionMode = useCallback(() => { |
| @@ -1980,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 29 unmodified lines | ||
| 1980 | 2186 | if (!serverThread) { |
| 1981 | 2187 | return; |
| 1982 | 2188 | } |
| 1983 | − const api = readNativeApi(); | |
| 2189 | + const api = readEnvironmentApi(environmentId); | |
| 1984 | 2190 | if (!api) { |
| 1985 | 2191 | return; |
| 1986 | 2192 | } |
| @@ -2020,7 +2226,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 33 unmodified lines | ||
| 2020 | 2226 | }); |
| 2021 | 2227 | } |
| 2022 | 2228 | }, |
| 2023 | − [serverThread], | |
| 2229 | + [environmentId, serverThread], | |
| 2024 | 2230 | ); |
| 2025 | 2231 | |
| 2026 | 2232 | // Auto-scroll on new messages |
| @@ -2350,17 +2556,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 323 unmodified lines | ||
| 2350 | 2556 | dragDepthRef.current = 0; |
| 2351 | 2557 | setIsDragOverComposer(false); |
| 2352 | 2558 | setExpandedImage(null); |
| 2353 | − }, [resetLocalDispatch, threadId]); | |
| 2559 | + }, [draftId, resetLocalDispatch, threadId]); | |
| 2354 | 2560 | |
| 2355 | 2561 | useEffect(() => { |
| 2356 | 2562 | let cancelled = false; |
| 2357 | 2563 | void (async () => { |
| 2358 | 2564 | if (composerImages.length === 0) { |
| 2359 | − clearComposerDraftPersistedAttachments(threadId); | |
| 2565 | + clearComposerDraftPersistedAttachments(composerDraftTarget); | |
| 2360 | 2566 | return; |
| 2361 | 2567 | } |
| 2362 | 2568 | const getPersistedAttachmentsForThread = () => |
| 2363 | − useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; | |
| 2569 | + getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; | |
| 2364 | 2570 | try { |
| 2365 | 2571 | const currentPersistedAttachments = getPersistedAttachmentsForThread(); |
| 2366 | 2572 | const existingPersistedById = new Map( |
| @@ -2391,7 +2597,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 24 unmodified lines | ||
| 2391 | 2597 | return; |
| 2392 | 2598 | } |
| 2393 | 2599 | // Stage attachments in persisted draft state first so persist middleware can write them. |
| 2394 | − syncComposerDraftPersistedAttachments(threadId, serialized); | |
| 2600 | + syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); | |
| 2395 | 2601 | } catch { |
| 2396 | 2602 | const currentImageIds = new Set(composerImages.map((image) => image.id)); |
| 2397 | 2603 | const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); |
| @@ -2405,17 +2611,18 @@ export default function ChatView({ threadId }: ChatViewProps) { · 7 unmodified lines | ||
| 2405 | 2611 | if (cancelled) { |
| 2406 | 2612 | return; |
| 2407 | 2613 | } |
| 2408 | − syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); | |
| 2614 | + syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); | |
| 2409 | 2615 | } |
| 2410 | 2616 | })(); |
| 2411 | 2617 | return () => { |
| 2412 | 2618 | cancelled = true; |
| 2413 | 2619 | }; |
| 2414 | 2620 | }, [ |
| 2621 | + composerDraftTarget, | |
| 2415 | 2622 | clearComposerDraftPersistedAttachments, |
| 2416 | 2623 | composerImages, |
| 2624 | + getComposerDraft, | |
| 2417 | 2625 | syncComposerDraftPersistedAttachments, |
| 2418 | − threadId, | |
| 2419 | 2626 | ]); |
| 2420 | 2627 | |
| 2421 | 2628 | const closeExpandedImage = useCallback(() => { |
| @@ -2476,7 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 54 unmodified lines | ||
| 2476 | 2683 | useEffect(() => { |
| 2477 | 2684 | if (!activeThreadId) { |
| 2478 | 2685 | setTerminalLaunchContext(null); |
| 2479 | − storeClearTerminalLaunchContext(threadId); | |
| 2686 | + storeClearTerminalLaunchContext(routeThreadRef); | |
| 2480 | 2687 | return; |
| 2481 | 2688 | } |
| 2482 | 2689 | setTerminalLaunchContext((current) => { |
| @@ -2484,7 +2691,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line | ||
| 2484 | 2691 | if (current.threadId === activeThreadId) return current; |
| 2485 | 2692 | return null; |
| 2486 | 2693 | }); |
| 2487 | − }, [activeThreadId, storeClearTerminalLaunchContext, threadId]); | |
| 2694 | + }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]); | |
| 2488 | 2695 | |
| 2489 | 2696 | useEffect(() => { |
| 2490 | 2697 | if (!activeThreadId || !activeProjectCwd) { |
| @@ -2502,12 +2709,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 11 unmodified lines | ||
| 2502 | 2709 | settledCwd === current.cwd && |
| 2503 | 2710 | (activeThreadWorktreePath ?? null) === current.worktreePath |
| 2504 | 2711 | ) { |
| 2505 | − storeClearTerminalLaunchContext(activeThreadId); | |
| 2712 | + if (activeThreadRef) { | |
| 2713 | + storeClearTerminalLaunchContext(activeThreadRef); | |
| 2714 | + } | |
| 2506 | 2715 | return null; |
| 2507 | 2716 | } |
| 2508 | 2717 | return current; |
| 2509 | 2718 | }); |
| 2510 | − }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]); | |
| 2719 | + }, [ | |
| 2720 | + activeProjectCwd, | |
| 2721 | + activeThreadId, | |
| 2722 | + activeThreadRef, | |
| 2723 | + activeThreadWorktreePath, | |
| 2724 | + storeClearTerminalLaunchContext, | |
| 2725 | + ]); | |
| 2511 | 2726 | |
| 2512 | 2727 | useEffect(() => { |
| 2513 | 2728 | if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { |
| @@ -2521,11 +2736,14 @@ export default function ChatView({ threadId }: ChatViewProps) { · 7 unmodified lines | ||
| 2521 | 2736 | settledCwd === storeServerTerminalLaunchContext.cwd && |
| 2522 | 2737 | (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath |
| 2523 | 2738 | ) { |
| 2524 | − storeClearTerminalLaunchContext(activeThreadId); | |
| 2739 | + if (activeThreadRef) { | |
| 2740 | + storeClearTerminalLaunchContext(activeThreadRef); | |
| 2741 | + } | |
| 2525 | 2742 | } |
| 2526 | 2743 | }, [ |
| 2527 | 2744 | activeProjectCwd, |
| 2528 | 2745 | activeThreadId, |
| 2746 | + activeThreadRef, | |
| 2529 | 2747 | activeThreadWorktreePath, |
| 2530 | 2748 | storeClearTerminalLaunchContext, |
| 2531 | 2749 | storeServerTerminalLaunchContext, |
| @@ -2535,11 +2753,16 @@ export default function ChatView({ threadId }: ChatViewProps) { · 3 unmodified lines | ||
| 2535 | 2753 | if (terminalState.terminalOpen) { |
| 2536 | 2754 | return; |
| 2537 | 2755 | } |
| 2538 | − if (activeThreadId) { | |
| 2539 | − storeClearTerminalLaunchContext(activeThreadId); | |
| 2756 | + if (activeThreadRef) { | |
| 2757 | + storeClearTerminalLaunchContext(activeThreadRef); | |
| 2540 | 2758 | } |
| 2541 | 2759 | setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); |
| 2542 | − }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]); | |
| 2760 | + }, [ | |
| 2761 | + activeThreadId, | |
| 2762 | + activeThreadRef, | |
| 2763 | + storeClearTerminalLaunchContext, | |
| 2764 | + terminalState.terminalOpen, | |
| 2765 | + ]); | |
| 2543 | 2766 | |
| 2544 | 2767 | useEffect(() => { |
| 2545 | 2768 | if (phase !== "running") return; |
| @@ -2552,16 +2775,16 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines | ||
| 2552 | 2775 | }, [phase]); |
| 2553 | 2776 | |
| 2554 | 2777 | useEffect(() => { |
| 2555 | − if (!activeThreadId) return; | |
| 2556 | − const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; | |
| 2778 | + if (!activeThreadKey) return; | |
| 2779 | + const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; | |
| 2557 | 2780 | const current = Boolean(terminalState.terminalOpen); |
| 2558 | 2781 | |
| 2559 | 2782 | if (!previous && current) { |
| 2560 | − terminalOpenByThreadRef.current[activeThreadId] = current; | |
| 2783 | + terminalOpenByThreadRef.current[activeThreadKey] = current; | |
| 2561 | 2784 | setTerminalFocusRequestId((value) => value + 1); |
| 2562 | 2785 | return; |
| 2563 | 2786 | } else if (previous && !current) { |
| 2564 | − terminalOpenByThreadRef.current[activeThreadId] = current; | |
| 2787 | + terminalOpenByThreadRef.current[activeThreadKey] = current; | |
| 2565 | 2788 | const frame = window.requestAnimationFrame(() => { |
| 2566 | 2789 | focusComposer(); |
| 2567 | 2790 | }); |
| @@ -2570,8 +2793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines | ||
| 2570 | 2793 | }; |
| 2571 | 2794 | } |
| 2572 | 2795 | |
| 2573 | − terminalOpenByThreadRef.current[activeThreadId] = current; | |
| 2574 | − }, [activeThreadId, focusComposer, terminalState.terminalOpen]); | |
| 2796 | + terminalOpenByThreadRef.current[activeThreadKey] = current; | |
| 2797 | + }, [activeThreadKey, focusComposer, terminalState.terminalOpen]); | |
| 2575 | 2798 | |
| 2576 | 2799 | useEffect(() => { |
| 2577 | 2800 | const handler = (event: globalThis.KeyboardEvent) => { |
| @@ -2766,14 +2989,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 188 unmodified lines | ||
| 2766 | 2989 | |
| 2767 | 2990 | const onRevertToTurnCount = useCallback( |
| 2768 | 2991 | async (turnCount: number) => { |
| 2769 | − const api = readNativeApi(); | |
| 2770 | − if (!api || !activeThread || isRevertingCheckpoint) return; | |
| 2992 | + const api = readEnvironmentApi(environmentId); | |
| 2993 | + const localApi = readLocalApi(); | |
| 2994 | + if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; | |
| 2771 | 2995 | |
| 2772 | 2996 | if (phase === "running" || isSendBusy || isConnecting) { |
| 2773 | 2997 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); |
| 2774 | 2998 | return; |
| 2775 | 2999 | } |
| 2776 | − const confirmed = await api.dialogs.confirm( | |
| 3000 | + const confirmed = await localApi.dialogs.confirm( | |
| 2777 | 3001 | [ |
| 2778 | 3002 | `Revert this thread to checkpoint ${turnCount}?`, |
| 2779 | 3003 | "This will discard newer messages and turn diffs in this thread.", |
| @@ -2802,12 +3026,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 22 unmodified lines | ||
| 2802 | 3026 | } |
| 2803 | 3027 | setIsRevertingCheckpoint(false); |
| 2804 | 3028 | }, |
| 2805 | − [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], | |
| 3029 | + [ | |
| 3030 | + activeThread, | |
| 3031 | + environmentId, | |
| 3032 | + isConnecting, | |
| 3033 | + isRevertingCheckpoint, | |
| 3034 | + isSendBusy, | |
| 3035 | + phase, | |
| 3036 | + setThreadError, | |
| 3037 | + ], | |
| 2806 | 3038 | ); |
| 2807 | 3039 | |
| 2808 | 3040 | const onSend = async (e?: { preventDefault: () => void }) => { |
| 2809 | 3041 | e?.preventDefault(); |
| 2810 | − const api = readNativeApi(); | |
| 3042 | + const api = readEnvironmentApi(environmentId); | |
| 2811 | 3043 | if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; |
| 2812 | 3044 | if (activePendingProgress) { |
| 2813 | 3045 | onAdvanceActivePendingUserInput(); |
| @@ -2830,7 +3062,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines | ||
| 2830 | 3062 | planMarkdown: activeProposedPlan.planMarkdown, |
| 2831 | 3063 | }); |
| 2832 | 3064 | promptRef.current = ""; |
| 2833 | − clearComposerDraftContent(activeThread.id); | |
| 3065 | + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); | |
| 2834 | 3066 | setComposerHighlightedItemId(null); |
| 2835 | 3067 | setComposerCursor(0); |
| 2836 | 3068 | setComposerTrigger(null); |
| @@ -2847,7 +3079,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 10 unmodified lines | ||
| 2847 | 3079 | if (standaloneSlashCommand) { |
| 2848 | 3080 | handleInteractionModeChange(standaloneSlashCommand); |
| 2849 | 3081 | promptRef.current = ""; |
| 2850 | − clearComposerDraftContent(activeThread.id); | |
| 3082 | + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); | |
| 2851 | 3083 | setComposerHighlightedItemId(null); |
| 2852 | 3084 | setComposerCursor(0); |
| 2853 | 3085 | setComposerTrigger(null); |
| @@ -2880,10 +3112,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 26 unmodified lines | ||
| 2880 | 3112 | const shouldCreateWorktree = |
| 2881 | 3113 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; |
| 2882 | 3114 | if (shouldCreateWorktree && !activeThread.branch) { |
| 2883 | − setStoreThreadError( | |
| 2884 | − threadIdForSend, | |
| 2885 | − "Select a base branch before sending in New worktree mode.", | |
| 2886 | − ); | |
| 3115 | + setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); | |
| 2887 | 3116 | return; |
| 2888 | 3117 | } |
| 2889 | 3118 | |
| @@ -2950,7 +3179,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 60 unmodified lines | ||
| 2950 | 3179 | }); |
| 2951 | 3180 | } |
| 2952 | 3181 | promptRef.current = ""; |
| 2953 | − clearComposerDraftContent(threadIdForSend); | |
| 3182 | + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend)); | |
| 2954 | 3183 | setComposerHighlightedItemId(null); |
| 2955 | 3184 | setComposerCursor(0); |
| 2956 | 3185 | setComposerTrigger(null); |
| @@ -3087,7 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 130 unmodified lines | ||
| 3087 | 3316 | }; |
| 3088 | 3317 | |
| 3089 | 3318 | const onInterrupt = async () => { |
| 3090 | − const api = readNativeApi(); | |
| 3319 | + const api = readEnvironmentApi(environmentId); | |
| 3091 | 3320 | if (!api || !activeThread) return; |
| 3092 | 3321 | await api.orchestration.dispatchCommand({ |
| 3093 | 3322 | type: "thread.turn.interrupt", |
| @@ -3099,7 +3328,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 5 unmodified lines | ||
| 3099 | 3328 | |
| 3100 | 3329 | const onRespondToApproval = useCallback( |
| 3101 | 3330 | async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { |
| 3102 | − const api = readNativeApi(); | |
| 3331 | + const api = readEnvironmentApi(environmentId); | |
| 3103 | 3332 | if (!api || !activeThreadId) return; |
| 3104 | 3333 | |
| 3105 | 3334 | setRespondingRequestIds((existing) => |
| @@ -3122,12 +3351,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines | ||
| 3122 | 3351 | }); |
| 3123 | 3352 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 3124 | 3353 | }, |
| 3125 | − [activeThreadId, setThreadError], | |
| 3354 | + [activeThreadId, environmentId, setThreadError], | |
| 3126 | 3355 | ); |
| 3127 | 3356 | |
| 3128 | 3357 | const onRespondToUserInput = useCallback( |
| 3129 | 3358 | async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => { |
| 3130 | − const api = readNativeApi(); | |
| 3359 | + const api = readEnvironmentApi(environmentId); | |
| 3131 | 3360 | if (!api || !activeThreadId) return; |
| 3132 | 3361 | |
| 3133 | 3362 | setRespondingUserInputRequestIds((existing) => |
| @@ -3150,7 +3379,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines | ||
| 3150 | 3379 | }); |
| 3151 | 3380 | setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 3152 | 3381 | }, |
| 3153 | − [activeThreadId, setThreadError], | |
| 3382 | + [activeThreadId, environmentId, setThreadError], | |
| 3154 | 3383 | ); |
| 3155 | 3384 | |
| 3156 | 3385 | const setActivePendingUserInputQuestionIndex = useCallback( |
| @@ -3166,31 +3395,38 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines | ||
| 3166 | 3395 | [activePendingUserInput], |
| 3167 | 3396 | ); |
| 3168 | 3397 | |
| 3169 | − const onToggleActivePendingUserInputOption = useCallback( | |
| 3398 | + const onSelectActivePendingUserInputOption = useCallback( | |
| 3170 | 3399 | (questionId: string, optionLabel: string) => { |
| 3171 | 3400 | if (!activePendingUserInput) { |
| 3172 | 3401 | return; |
| 3173 | 3402 | } |
| 3174 | − const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); | |
| 3175 | − if (!question) { | |
| 3176 | − return; | |
| 3177 | − } | |
| 3178 | − setPendingUserInputAnswersByRequestId((existing) => ({ | |
| 3179 | − ...existing, | |
| 3180 | − [activePendingUserInput.requestId]: { | |
| 3181 | − ...existing[activePendingUserInput.requestId], | |
| 3182 | − [questionId]: togglePendingUserInputOptionSelection( | |
| 3183 | − question, | |
| 3184 | − existing[activePendingUserInput.requestId]?.[questionId], | |
| 3185 | − optionLabel, | |
| 3186 | − ), | |
| 3187 | − }, | |
| 3188 | − })); | |
| 3403 | + setPendingUserInputAnswersByRequestId((existing) => { | |
| 3404 | + const question = | |
| 3405 | + (activePendingProgress?.activeQuestion?.id === questionId | |
| 3406 | + ? activePendingProgress.activeQuestion | |
| 3407 | + : undefined) ?? | |
| 3408 | + activePendingUserInput.questions.find((entry) => entry.id === questionId); | |
| 3409 | + if (!question) { | |
| 3410 | + return existing; | |
| 3411 | + } | |
| 3412 | + | |
| 3413 | + return { | |
| 3414 | + ...existing, | |
| 3415 | + [activePendingUserInput.requestId]: { | |
| 3416 | + ...existing[activePendingUserInput.requestId], | |
| 3417 | + [questionId]: togglePendingUserInputOptionSelection( | |
| 3418 | + question, | |
| 3419 | + existing[activePendingUserInput.requestId]?.[questionId], | |
| 3420 | + optionLabel, | |
| 3421 | + ), | |
| 3422 | + }, | |
| 3423 | + }; | |
| 3424 | + }); | |
| 3189 | 3425 | promptRef.current = ""; |
| 3190 | 3426 | setComposerCursor(0); |
| 3191 | 3427 | setComposerTrigger(null); |
| 3192 | 3428 | }, |
| 3193 | − [activePendingUserInput], | |
| 3429 | + [activePendingProgress?.activeQuestion, activePendingUserInput], | |
| 3194 | 3430 | ); |
| 3195 | 3431 | |
| 3196 | 3432 | const onChangeActivePendingUserInputCustomAnswer = useCallback( |
| @@ -3257,7 +3493,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 60 unmodified lines | ||
| 3257 | 3493 | text: string; |
| 3258 | 3494 | interactionMode: "default" | "plan"; |
| 3259 | 3495 | }) => { |
| 3260 | − const api = readNativeApi(); | |
| 3496 | + const api = readEnvironmentApi(environmentId); | |
| 3261 | 3497 | if ( |
| 3262 | 3498 | !api || |
| 3263 | 3499 | !activeThread || |
| @@ -3312,7 +3548,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 48 unmodified lines | ||
| 3312 | 3548 | |
| 3313 | 3549 | // Keep the mode toggle and plan-follow-up banner in sync immediately |
| 3314 | 3550 | // while the same-thread implementation turn is starting. |
| 3315 | − setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); | |
| 3551 | + setComposerDraftInteractionMode( | |
| 3552 | + scopeThreadRef(activeThread.environmentId, threadIdForSend), | |
| 3553 | + nextInteractionMode, | |
| 3554 | + ); | |
| 3316 | 3555 | |
| 3317 | 3556 | await api.orchestration.dispatchCommand({ |
| 3318 | 3557 | type: "thread.turn.start", |
| @@ -3376,11 +3615,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 57 unmodified lines | ||
| 3376 | 3615 | setComposerDraftInteractionMode, |
| 3377 | 3616 | setThreadError, |
| 3378 | 3617 | selectedModel, |
| 3618 | + environmentId, | |
| 3379 | 3619 | ], |
| 3380 | 3620 | ); |
| 3381 | 3621 | |
| 3382 | 3622 | const onImplementPlanInNewThread = useCallback(async () => { |
| 3383 | − const api = readNativeApi(); | |
| 3623 | + const api = readEnvironmentApi(environmentId); | |
| 3384 | 3624 | if ( |
| 3385 | 3625 | !api || |
| 3386 | 3626 | !activeThread || |
| @@ -3452,17 +3692,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 65 unmodified lines | ||
| 3452 | 3692 | }); |
| 3453 | 3693 | }) |
| 3454 | 3694 | .then(() => { |
| 3455 | − return waitForStartedServerThread(nextThreadId); | |
| 3695 | + return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); | |
| 3456 | 3696 | }) |
| 3457 | 3697 | .then(() => { |
| 3458 | 3698 | // Signal that the plan sidebar should open on the new thread. |
| 3459 | 3699 | planSidebarOpenOnNextThreadRef.current = true; |
| 3460 | 3700 | return navigate({ |
| 3461 | − to: "/$threadId", | |
| 3462 | − params: { threadId: nextThreadId }, | |
| 3701 | + to: "/$environmentId/$threadId", | |
| 3702 | + params: { | |
| 3703 | + environmentId: activeThread.environmentId, | |
| 3704 | + threadId: nextThreadId, | |
| 3705 | + }, | |
| 3463 | 3706 | }); |
| 3464 | 3707 | }) |
| 3465 | − .catch(async (err) => { | |
| 3708 | + .catch(async (err: unknown) => { | |
| 3466 | 3709 | await api.orchestration |
| 3467 | 3710 | .dispatchCommand({ |
| 3468 | 3711 | type: "thread.delete", |
| @@ -3494,6 +3737,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 25 unmodified lines | ||
| 3494 | 3737 | selectedProvider, |
| 3495 | 3738 | selectedProviderModels, |
| 3496 | 3739 | selectedModel, |
| 3740 | + environmentId, | |
| 3497 | 3741 | ]); |
| 3498 | 3742 | |
| 3499 | 3743 | const onProviderModelSelect = useCallback( |
| @@ -3514,7 +3758,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 14 unmodified lines | ||
| 3514 | 3758 | provider: resolvedProvider, |
| 3515 | 3759 | model: resolvedModel, |
| 3516 | 3760 | }; |
| 3517 | − setComposerDraftModelSelection(activeThread.id, nextModelSelection); | |
| 3761 | + setComposerDraftModelSelection( | |
| 3762 | + scopeThreadRef(activeThread.environmentId, activeThread.id), | |
| 3763 | + nextModelSelection, | |
| 3764 | + ); | |
| 3518 | 3765 | setStickyComposerModelSelection(nextModelSelection); |
| 3519 | 3766 | scheduleComposerFocus(); |
| 3520 | 3767 | }, |
| @@ -3546,7 +3793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 25 unmodified lines | ||
| 3546 | 3793 | ); |
| 3547 | 3794 | const providerTraitsMenuContent = renderProviderTraitsMenuContent({ |
| 3548 | 3795 | provider: selectedProvider, |
| 3549 | − threadId, | |
| 3796 | + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), | |
| 3797 | + ...(routeKind === "draft" && draftId ? { draftId } : {}), | |
| 3550 | 3798 | model: selectedModel, |
| 3551 | 3799 | models: selectedProviderModels, |
| 3552 | 3800 | modelOptions: composerModelOptions?.[selectedProvider], |
| @@ -3555,7 +3803,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines | ||
| 3555 | 3803 | }); |
| 3556 | 3804 | const providerTraitsPicker = renderProviderTraitsPicker({ |
| 3557 | 3805 | provider: selectedProvider, |
| 3558 | − threadId, | |
| 3806 | + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), | |
| 3807 | + ...(routeKind === "draft" && draftId ? { draftId } : {}), | |
| 3559 | 3808 | model: selectedModel, |
| 3560 | 3809 | models: selectedProviderModels, |
| 3561 | 3810 | modelOptions: composerModelOptions?.[selectedProvider], |
| @@ -3565,11 +3814,11 @@ export default function ChatView({ threadId }: ChatViewProps) { · 3 unmodified lines | ||
| 3565 | 3814 | const onEnvModeChange = useCallback( |
| 3566 | 3815 | (mode: DraftThreadEnvMode) => { |
| 3567 | 3816 | if (isLocalDraftThread) { |
| 3568 | − setDraftThreadContext(threadId, { envMode: mode }); | |
| 3817 | + setDraftThreadContext(composerDraftTarget, { envMode: mode }); | |
| 3569 | 3818 | } |
| 3570 | 3819 | scheduleComposerFocus(); |
| 3571 | 3820 | }, |
| 3572 | − [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], | |
| 3821 | + [composerDraftTarget, isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext], | |
| 3573 | 3822 | ); |
| 3574 | 3823 | |
| 3575 | 3824 | const applyPromptReplacement = useCallback( |
| @@ -3766,7 +4015,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 190 unmodified lines | ||
| 3766 | 4015 | setPrompt(nextPrompt); |
| 3767 | 4016 | if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { |
| 3768 | 4017 | setComposerDraftTerminalContexts( |
| 3769 | − threadId, | |
| 4018 | + composerDraftTarget, | |
| 3770 | 4019 | syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), |
| 3771 | 4020 | ); |
| 3772 | 4021 | } |
| @@ -3781,8 +4030,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 8 unmodified lines | ||
| 3781 | 4030 | composerTerminalContexts, |
| 3782 | 4031 | onChangeActivePendingUserInputCustomAnswer, |
| 3783 | 4032 | setPrompt, |
| 4033 | + composerDraftTarget, | |
| 3784 | 4034 | setComposerDraftTerminalContexts, |
| 3785 | − threadId, | |
| 3786 | 4035 | ], |
| 3787 | 4036 | ); |
| 3788 | 4037 | |
| @@ -3835,9 +4084,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 46 unmodified lines | ||
| 3835 | 4084 | const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; |
| 3836 | 4085 | const onOpenTurnDiff = useCallback( |
| 3837 | 4086 | (turnId: TurnId, filePath?: string) => { |
| 4087 | + if (!isServerThread) { | |
| 4088 | + return; | |
| 4089 | + } | |
| 3838 | 4090 | void navigate({ |
| 3839 | − to: "/$threadId", | |
| 3840 | − params: { threadId }, | |
| 4091 | + to: "/$environmentId/$threadId", | |
| 4092 | + params: { | |
| 4093 | + environmentId, | |
| 4094 | + threadId, | |
| 4095 | + }, | |
| 3841 | 4096 | search: (previous) => { |
| 3842 | 4097 | const rest = stripDiffSearchParams(previous); |
| 3843 | 4098 | return filePath |
| @@ -3846,7 +4101,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines | ||
| 3846 | 4101 | }, |
| 3847 | 4102 | }); |
| 3848 | 4103 | }, |
| 3849 | − [navigate, threadId], | |
| 4104 | + [environmentId, isServerThread, navigate, threadId], | |
| 3850 | 4105 | ); |
| 3851 | 4106 | const onRevertUserMessage = (messageId: MessageId) => { |
| 3852 | 4107 | const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); |
| @@ -3892,6 +4147,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 39 unmodified lines | ||
| 3892 | 4147 | )} |
| 3893 | 4148 | > |
| 3894 | 4149 | <ChatHeader |
| 4150 | + activeThreadEnvironmentId={activeThread.environmentId} | |
| 3895 | 4151 | activeThreadId={activeThread.id} |
| 3896 | 4152 | activeThreadTitle={activeThread.title} |
| 3897 | 4153 | activeProjectName={activeProject?.name} |
| @@ -3959,6 +4215,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 61 unmodified lines | ||
| 3959 | 4215 | completionSummary={completionSummary} |
| 3960 | 4216 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} |
| 3961 | 4217 | nowIso={nowIso} |
| 4218 | + activeThreadEnvironmentId={activeThread.environmentId} | |
| 3962 | 4219 | expandedWorkGroups={expandedWorkGroups} |
| 3963 | 4220 | onToggleWorkGroup={onToggleWorkGroup} |
| 3964 | 4221 | onOpenTurnDiff={onOpenTurnDiff} |
| @@ -4027,7 +4284,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 62 unmodified lines | ||
| 4027 | 4284 | respondingRequestIds={respondingRequestIds} |
| 4028 | 4285 | answers={activePendingDraftAnswers} |
| 4029 | 4286 | questionIndex={activePendingQuestionIndex} |
| 4030 | − onToggleOption={onToggleActivePendingUserInputOption} | |
| 4287 | + onToggleOption={onSelectActivePendingUserInputOption} | |
| 4031 | 4288 | onAdvance={onAdvanceActivePendingUserInput} |
| 4032 | 4289 | /> |
| 4033 | 4290 | </div> |
| @@ -4370,7 +4627,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 336 unmodified lines | ||
| 4370 | 4627 | |
| 4371 | 4628 | {isGitRepo && ( |
| 4372 | 4629 | <BranchToolbar |
| 4630 | + environmentId={activeThread.environmentId} | |
| 4373 | 4631 | threadId={activeThread.id} |
| 4632 | + {...(routeKind === "draft" && draftId ? { draftId } : {})} | |
| 4374 | 4633 | onEnvModeChange={onEnvModeChange} |
| 4375 | 4634 | envLocked={envLocked} |
| 4376 | 4635 | onComposerFocusRequest={scheduleComposerFocus} |
| @@ -4383,6 +4642,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 6 unmodified lines | ||
| 4383 | 4642 | <PullRequestThreadDialog |
| 4384 | 4643 | key={pullRequestDialogState.key} |
| 4385 | 4644 | open |
| 4645 | + environmentId={activeThread.environmentId} | |
| 4386 | 4646 | threadId={activeThread.id} |
| 4387 | 4647 | cwd={activeProject?.cwd ?? null} |
| 4388 | 4648 | initialReference={pullRequestDialogState.initialReference} |
| @@ -4402,6 +4662,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 13 unmodified lines | ||
| 4402 | 4662 | <PlanSidebar |
| 4403 | 4663 | activePlan={activePlan} |
| 4404 | 4664 | activeProposedPlan={sidebarProposedPlan} |
| 4665 | + environmentId={environmentId} | |
| 4405 | 4666 | markdownCwd={gitCwd ?? undefined} |
| 4406 | 4667 | workspaceRoot={activeWorkspaceRoot} |
| 4407 | 4668 | timestampFormat={timestampFormat} |
| @@ -4418,21 +4679,28 @@ export default function ChatView({ threadId }: ChatViewProps) { · 10 unmodified lines | ||
| 4418 | 4679 | </div> |
| 4419 | 4680 | {/* end horizontal flex container */} |
| 4420 | 4681 | |
| 4421 | − {mountedTerminalThreadIds.map((mountedThreadId) => ( | |
| 4422 | − <PersistentThreadTerminalDrawer | |
| 4423 | − key={mountedThreadId} | |
| 4424 | − threadId={mountedThreadId} | |
| 4425 | − visible={mountedThreadId === activeThreadId && terminalState.terminalOpen} | |
| 4426 | − launchContext={ | |
| 4427 | − mountedThreadId === activeThreadId ? (activeTerminalLaunchContext ?? null) : null | |
| 4428 | − } | |
| 4429 | − focusRequestId={mountedThreadId === activeThreadId ? terminalFocusRequestId : 0} | |
| 4430 | − splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | |
| 4431 | − newShortcutLabel={newTerminalShortcutLabel ?? undefined} | |
| 4432 | − closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | |
| 4433 | − onAddTerminalContext={addTerminalContextToDraft} | |
| 4434 | − /> | |
| 4435 | − ))} | |
| 4682 | + {mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { | |
| 4683 | + const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); | |
| 4684 | + if (!mountedThreadRef) { | |
| 4685 | + return []; | |
| 4686 | + } | |
| 4687 | + return [ | |
| 4688 | + <PersistentThreadTerminalDrawer | |
| 4689 | + key={mountedThreadKey} | |
| 4690 | + threadRef={mountedThreadRef} | |
| 4691 | + threadId={mountedThreadRef.threadId} | |
| 4692 | + visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen} | |
| 4693 | + launchContext={ | |
| 4694 | + mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null | |
| 4695 | + } | |
| 4696 | + focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0} | |
| 4697 | + splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | |
| 4698 | + newShortcutLabel={newTerminalShortcutLabel ?? undefined} | |
| 4699 | + closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | |
| 4700 | + onAddTerminalContext={addTerminalContextToDraft} | |
| 4701 | + />, | |
| 4702 | + ]; | |
| 4703 | + })} | |
| 4436 | 4704 | |
| 4437 | 4705 | {expandedImage && expandedImageItem && ( |
| 4438 | 4706 | <div |
| @@ -2,7 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs"; · 1 unmodified line | ||
| 2 | 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; |
| 3 | 3 | import { useQuery } from "@tanstack/react-query"; |
| 4 | 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | −import { ThreadId, type TurnId } from "@t3tools/contracts"; | |
| 5 | +import { scopeThreadRef } from "@t3tools/client-runtime"; | |
| 6 | +import type { TurnId } from "@t3tools/contracts"; | |
| 6 | 7 | import { |
| 7 | 8 | ChevronLeftIcon, |
| 8 | 9 | ChevronRightIcon, |
| @@ -22,15 +23,16 @@ import { openInPreferredEditor } from "../editorPreferences"; · 13 unmodified lines | ||
| 22 | 23 | import { useGitStatus } from "~/lib/gitStatusState"; |
| 23 | 24 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 24 | 25 | import { cn } from "~/lib/utils"; |
| 25 | −import { readNativeApi } from "../nativeApi"; | |
| 26 | +import { readLocalApi } from "../localApi"; | |
| 26 | 27 | import { resolvePathLinkTarget } from "../terminal-links"; |
| 27 | 28 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 28 | 29 | import { useTheme } from "../hooks/useTheme"; |
| 29 | 30 | import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 30 | 31 | import { resolveDiffThemeName } from "../lib/diffRendering"; |
| 31 | 32 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 32 | −import { useStore } from "../store"; | |
| 33 | −import { createThreadSelector } from "../storeSelectors"; | |
| 33 | +import { selectProjectByRef, useStore } from "../store"; | |
| 34 | +import { createThreadSelectorByRef } from "../storeSelectors"; | |
| 35 | +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | |
| 34 | 36 | import { useSettings } from "../hooks/useSettings"; |
| 35 | 37 | import { formatShortTimestamp } from "../timestampFormat"; |
| 36 | 38 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; |
| @@ -175,22 +177,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 138 unmodified lines | ||
| 175 | 177 | const previousDiffOpenRef = useRef(false); |
| 176 | 178 | const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); |
| 177 | 179 | const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); |
| 178 | − const routeThreadId = useParams({ | |
| 180 | + const routeThreadRef = useParams({ | |
| 179 | 181 | strict: false, |
| 180 | − select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | |
| 182 | + select: (params) => resolveThreadRouteRef(params), | |
| 181 | 183 | }); |
| 182 | 184 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); |
| 183 | 185 | const diffOpen = diffSearch.diff === "1"; |
| 184 | − const activeThreadId = routeThreadId; | |
| 186 | + const activeThreadId = routeThreadRef?.threadId ?? null; | |
| 185 | 187 | const activeThread = useStore( |
| 186 | − useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]), | |
| 188 | + useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), | |
| 187 | 189 | ); |
| 188 | 190 | const activeProjectId = activeThread?.projectId ?? null; |
| 189 | 191 | const activeProject = useStore((store) => |
| 190 | − activeProjectId ? store.projectById[activeProjectId] : undefined, | |
| 192 | + activeThread && activeProjectId | |
| 193 | + ? selectProjectByRef(store, { | |
| 194 | + environmentId: activeThread.environmentId, | |
| 195 | + projectId: activeProjectId, | |
| 196 | + }) | |
| 197 | + : undefined, | |
| 191 | 198 | ); |
| 192 | 199 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 193 | − const gitStatusQuery = useGitStatus(activeCwd ?? null); | |
| 200 | + const gitStatusQuery = useGitStatus({ | |
| 201 | + environmentId: activeThread?.environmentId ?? null, | |
| 202 | + cwd: activeCwd ?? null, | |
| 203 | + }); | |
| 194 | 204 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; |
| 195 | 205 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 196 | 206 | useTurnDiffSummaries(activeThread); |
| @@ -263,6 +273,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 66 unmodified lines | ||
| 263 | 273 | }, [orderedTurnDiffSummaries, selectedTurn]); |
| 264 | 274 | const activeCheckpointDiffQuery = useQuery( |
| 265 | 275 | checkpointDiffQueryOptions({ |
| 276 | + environmentId: activeThread?.environmentId ?? null, | |
| 266 | 277 | threadId: activeThreadId, |
| 267 | 278 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, |
| 268 | 279 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, |
| @@ -322,7 +333,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 53 unmodified lines | ||
| 322 | 333 | |
| 323 | 334 | const openDiffFileInEditor = useCallback( |
| 324 | 335 | (filePath: string) => { |
| 325 | − const api = readNativeApi(); | |
| 336 | + const api = readLocalApi(); | |
| 326 | 337 | if (!api) return; |
| 327 | 338 | const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; |
| 328 | 339 | void openInPreferredEditor(api, targetPath).catch((error) => { |
| @@ -335,8 +346,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 6 unmodified lines | ||
| 335 | 346 | const selectTurn = (turnId: TurnId) => { |
| 336 | 347 | if (!activeThread) return; |
| 337 | 348 | void navigate({ |
| 338 | − to: "/$threadId", | |
| 339 | − params: { threadId: activeThread.id }, | |
| 349 | + to: "/$environmentId/$threadId", | |
| 350 | + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), | |
| 340 | 351 | search: (previous) => { |
| 341 | 352 | const rest = stripDiffSearchParams(previous); |
| 342 | 353 | return { ...rest, diff: "1", diffTurnId: turnId }; |
| @@ -346,8 +357,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 3 unmodified lines | ||
| 346 | 357 | const selectWholeConversation = () => { |
| 347 | 358 | if (!activeThread) return; |
| 348 | 359 | void navigate({ |
| 349 | − to: "/$threadId", | |
| 350 | − params: { threadId: activeThread.id }, | |
| 360 | + to: "/$environmentId/$threadId", | |
| 361 | + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), | |
| 351 | 362 | search: (previous) => { |
| 352 | 363 | const rest = stripDiffSearchParams(previous); |
| 353 | 364 | return { ...rest, diff: "1" }; |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { scopeThreadRef } from "@t3tools/client-runtime"; | |
| 1 | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | 3 | import { useState } from "react"; |
| 3 | 4 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| @@ -5,10 +6,24 @@ import { render } from "vitest-browser-react"; · 1 unmodified line | ||
| 5 | 6 | |
| 6 | 7 | const THREAD_A = ThreadId.makeUnsafe("thread-a"); |
| 7 | 8 | const THREAD_B = ThreadId.makeUnsafe("thread-b"); |
| 9 | +const ENVIRONMENT_ID = "environment-local" as never; | |
| 8 | 10 | const GIT_CWD = "/repo/project"; |
| 9 | 11 | const BRANCH_NAME = "feature/toast-scope"; |
| 10 | 12 | |
| 13 | +function createDeferredPromise<T>() { | |
| 14 | + let resolve!: (value: T) => void; | |
| 15 | + let reject!: (reason?: unknown) => void; | |
| 16 | + | |
| 17 | + const promise = new Promise<T>((nextResolve, nextReject) => { | |
| 18 | + resolve = nextResolve; | |
| 19 | + reject = nextReject; | |
| 20 | + }); | |
| 21 | + | |
| 22 | + return { promise, resolve, reject }; | |
| 23 | +} | |
| 24 | + | |
| 11 | 25 | const { |
| 26 | + activeRunStackedActionDeferredRef, | |
| 12 | 27 | invalidateGitQueriesSpy, |
| 13 | 28 | refreshGitStatusSpy, |
| 14 | 29 | runStackedActionMutateAsyncSpy, |
| @@ -18,9 +33,10 @@ const { · 3 unmodified lines | ||
| 18 | 33 | toastPromiseSpy, |
| 19 | 34 | toastUpdateSpy, |
| 20 | 35 | } = vi.hoisted(() => ({ |
| 36 | + activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() }, | |
| 21 | 37 | invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), |
| 22 | 38 | refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), |
| 23 | − runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)), | |
| 39 | + runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), | |
| 24 | 40 | setThreadBranchSpy: vi.fn(), |
| 25 | 41 | toastAddSpy: vi.fn(() => "toast-1"), |
| 26 | 42 | toastCloseSpy: vi.fn(), |
| @@ -28,12 +44,8 @@ const { · 1 unmodified line | ||
| 28 | 44 | toastUpdateSpy: vi.fn(), |
| 29 | 45 | })); |
| 30 | 46 | |
| 31 | −vi.mock("@tanstack/react-query", async () => { | |
| 32 | − const actual = | |
| 33 | − await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query"); | |
| 34 | − | |
| 47 | +vi.mock("@tanstack/react-query", () => { | |
| 35 | 48 | return { |
| 36 | − ...actual, | |
| 37 | 49 | useIsMutating: vi.fn(() => 0), |
| 38 | 50 | useMutation: vi.fn((options: { __kind?: string }) => { |
| 39 | 51 | if (options.__kind === "run-stacked-action") { |
| @@ -56,27 +68,7 @@ vi.mock("@tanstack/react-query", async () => { · 16 unmodified lines | ||
| 56 | 68 | isPending: false, |
| 57 | 69 | }; |
| 58 | 70 | }), |
| 59 | − useQuery: vi.fn((options: { queryKey?: string[] }) => { | |
| 60 | − if (options.queryKey?.[0] === "git-branches") { | |
| 61 | − return { | |
| 62 | − data: { | |
| 63 | − isRepo: true, | |
| 64 | − hasOriginRemote: true, | |
| 65 | − branches: [ | |
| 66 | − { | |
| 67 | − name: BRANCH_NAME, | |
| 68 | − current: true, | |
| 69 | − isDefault: false, | |
| 70 | − worktreePath: null, | |
| 71 | − }, | |
| 72 | − ], | |
| 73 | − }, | |
| 74 | − error: null, | |
| 75 | − }; | |
| 76 | − } | |
| 77 | − | |
| 78 | − return { data: null, error: null }; | |
| 79 | − }), | |
| 71 | + useQuery: vi.fn(() => ({ data: null, error: null })), | |
| 80 | 72 | useQueryClient: vi.fn(() => ({})), |
| 81 | 73 | }; |
| 82 | 74 | }); |
| @@ -123,27 +115,44 @@ vi.mock("~/lib/gitStatusState", () => ({ · 40 unmodified lines | ||
| 123 | 115 | })), |
| 124 | 116 | })); |
| 125 | 117 | |
| 126 | −vi.mock("~/lib/utils", async () => { | |
| 127 | − const actual = await vi.importActual<typeof import("~/lib/utils")>("~/lib/utils"); | |
| 128 | − | |
| 129 | − return { | |
| 130 | − ...actual, | |
| 131 | − newCommandId: vi.fn(() => "command-1"), | |
| 132 | − randomUUID: vi.fn(() => "action-1"), | |
| 133 | − }; | |
| 134 | −}); | |
| 135 | − | |
| 136 | −vi.mock("~/nativeApi", () => ({ | |
| 137 | − readNativeApi: vi.fn(() => null), | |
| 118 | +vi.mock("~/localApi", () => ({ | |
| 119 | + readLocalApi: vi.fn(() => null), | |
| 138 | 120 | })); |
| 139 | 121 | |
| 140 | 122 | vi.mock("~/store", () => ({ |
| 123 | + selectEnvironmentState: ( | |
| 124 | + state: { environmentStateById: Record<string, unknown> }, | |
| 125 | + environmentId: string | null, | |
| 126 | + ) => { | |
| 127 | + if (!environmentId) { | |
| 128 | + throw new Error("Missing environment id"); | |
| 129 | + } | |
| 130 | + const environmentState = state.environmentStateById[environmentId]; | |
| 131 | + if (!environmentState) { | |
| 132 | + throw new Error(`Unknown environment: ${environmentId}`); | |
| 133 | + } | |
| 134 | + return environmentState; | |
| 135 | + }, | |
| 141 | 136 | useStore: (selector: (state: unknown) => unknown) => |
| 142 | 137 | selector({ |
| 143 | 138 | setThreadBranch: setThreadBranchSpy, |
| 144 | − threadShellById: { | |
| 145 | − [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, | |
| 146 | − [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, | |
| 139 | + environmentStateById: { | |
| 140 | + [ENVIRONMENT_ID]: { | |
| 141 | + threadShellById: { | |
| 142 | + [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, | |
| 143 | + [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, | |
| 144 | + }, | |
| 145 | + threadSessionById: {}, | |
| 146 | + threadTurnStateById: {}, | |
| 147 | + messageIdsByThreadId: {}, | |
| 148 | + messageByThreadId: {}, | |
| 149 | + activityIdsByThreadId: {}, | |
| 150 | + activityByThreadId: {}, | |
| 151 | + proposedPlanIdsByThreadId: {}, | |
| 152 | + proposedPlanByThreadId: {}, | |
| 153 | + turnDiffIdsByThreadId: {}, | |
| 154 | + turnDiffSummaryByThreadId: {}, | |
| 155 | + }, | |
| 147 | 156 | }, |
| 148 | 157 | }), |
| 149 | 158 | })); |
| @@ -168,7 +177,10 @@ function Harness() { · 18 unmodified lines | ||
| 168 | 177 | <button type="button" onClick={() => setActiveThreadId(THREAD_B)}> |
| 169 | 178 | Switch thread |
| 170 | 179 | </button> |
| 171 | − <GitActionsControl gitCwd={GIT_CWD} activeThreadId={activeThreadId} /> | |
| 180 | + <GitActionsControl | |
| 181 | + gitCwd={GIT_CWD} | |
| 182 | + activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, activeThreadId)} | |
| 183 | + /> | |
| 172 | 184 | </> |
| 173 | 185 | ); |
| 174 | 186 | } |
| @@ -177,6 +189,7 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 2 unmodified lines | ||
| 177 | 189 | afterEach(() => { |
| 178 | 190 | vi.useRealTimers(); |
| 179 | 191 | vi.clearAllMocks(); |
| 192 | + activeRunStackedActionDeferredRef.current = createDeferredPromise<never>(); | |
| 180 | 193 | document.body.innerHTML = ""; |
| 181 | 194 | }); |
| 182 | 195 | |
| @@ -231,6 +244,9 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 48 unmodified lines | ||
| 231 | 244 | }), |
| 232 | 245 | ); |
| 233 | 246 | } finally { |
| 247 | + activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup")); | |
| 248 | + await Promise.resolve(); | |
| 249 | + vi.useRealTimers(); | |
| 234 | 250 | await screen.unmount(); |
| 235 | 251 | host.remove(); |
| 236 | 252 | } |
| @@ -248,9 +264,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 11 unmodified lines | ||
| 248 | 264 | |
| 249 | 265 | const host = document.createElement("div"); |
| 250 | 266 | document.body.append(host); |
| 251 | − const screen = await render(<GitActionsControl gitCwd={GIT_CWD} activeThreadId={THREAD_A} />, { | |
| 252 | − container: host, | |
| 253 | − }); | |
| 267 | + const screen = await render( | |
| 268 | + <GitActionsControl | |
| 269 | + gitCwd={GIT_CWD} | |
| 270 | + activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, THREAD_A)} | |
| 271 | + />, | |
| 272 | + { | |
| 273 | + container: host, | |
| 274 | + }, | |
| 275 | + ); | |
| 254 | 276 | |
| 255 | 277 | try { |
| 256 | 278 | window.dispatchEvent(new Event("focus")); |
| @@ -264,11 +286,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { · 7 unmodified lines | ||
| 264 | 286 | |
| 265 | 287 | await vi.advanceTimersByTimeAsync(1); |
| 266 | 288 | expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); |
| 267 | − expect(refreshGitStatusSpy).toHaveBeenCalledWith(GIT_CWD); | |
| 289 | + expect(refreshGitStatusSpy).toHaveBeenCalledWith({ | |
| 290 | + environmentId: ENVIRONMENT_ID, | |
| 291 | + cwd: GIT_CWD, | |
| 292 | + }); | |
| 268 | 293 | } finally { |
| 269 | 294 | if (originalVisibilityState) { |
| 270 | 295 | Object.defineProperty(document, "visibilityState", originalVisibilityState); |
| 271 | 296 | } |
| 297 | + vi.useRealTimers(); | |
| 272 | 298 | await screen.unmount(); |
| 273 | 299 | host.remove(); |
| 274 | 300 | } |
| @@ -1,9 +1,9 @@ | ||
| 1 | +import { type ScopedThreadRef } from "@t3tools/contracts"; | |
| 1 | 2 | import type { |
| 2 | 3 | GitActionProgressEvent, |
| 3 | 4 | GitRunStackedActionResult, |
| 4 | 5 | GitStackedAction, |
| 5 | 6 | GitStatusResult, |
| 6 | − ThreadId, | |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; |
| 9 | 9 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| @@ -49,12 +49,14 @@ import { · 39 unmodified lines | ||
| 49 | 49 | import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; |
| 50 | 50 | import { newCommandId, randomUUID } from "~/lib/utils"; |
| 51 | 51 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| 52 | −import { readNativeApi } from "~/nativeApi"; | |
| 52 | +import { readEnvironmentApi } from "~/environmentApi"; | |
| 53 | +import { readLocalApi } from "~/localApi"; | |
| 53 | 54 | import { useStore } from "~/store"; |
| 55 | +import { createThreadSelectorByRef } from "~/storeSelectors"; | |
| 54 | 56 | |
| 55 | 57 | interface GitActionsControlProps { |
| 56 | 58 | gitCwd: string | null; |
| 57 | − activeThreadId: ThreadId | null; | |
| 59 | + activeThreadRef: ScopedThreadRef | null; | |
| 58 | 60 | } |
| 59 | 61 | |
| 60 | 62 | interface PendingDefaultBranchAction { |
| @@ -206,14 +208,18 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { · 145 unmodified lines | ||
| 206 | 208 | return <InfoIcon className={iconClassName} />; |
| 207 | 209 | } |
| 208 | 210 | |
| 209 | −export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { | |
| 211 | +export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) { | |
| 212 | + const activeThreadId = activeThreadRef?.threadId ?? null; | |
| 213 | + const activeEnvironmentId = activeThreadRef?.environmentId ?? null; | |
| 210 | 214 | const threadToastData = useMemo( |
| 211 | 215 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 212 | 216 | [activeThreadId], |
| 213 | 217 | ); |
| 214 | − const activeServerThread = useStore((store) => | |
| 215 | − activeThreadId ? store.threadShellById[activeThreadId] : undefined, | |
| 218 | + const activeServerThreadSelector = useMemo( | |
| 219 | + () => createThreadSelectorByRef(activeThreadRef), | |
| 220 | + [activeThreadRef], | |
| 216 | 221 | ); |
| 222 | + const activeServerThread = useStore(activeServerThreadSelector); | |
| 217 | 223 | const setThreadBranch = useStore((store) => store.setThreadBranch); |
| 218 | 224 | const queryClient = useQueryClient(); |
| 219 | 225 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); |
| @@ -246,7 +252,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 26 unmodified lines | ||
| 246 | 252 | } |
| 247 | 253 | |
| 248 | 254 | const worktreePath = activeServerThread.worktreePath; |
| 249 | − const api = readNativeApi(); | |
| 255 | + const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; | |
| 250 | 256 | if (api) { |
| 251 | 257 | void api.orchestration |
| 252 | 258 | .dispatchCommand({ |
| @@ -261,7 +267,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 8 unmodified lines | ||
| 261 | 267 | |
| 262 | 268 | setThreadBranch(activeThreadId, branch, worktreePath); |
| 263 | 269 | }, |
| 264 | − [activeServerThread, activeThreadId, setThreadBranch], | |
| 270 | + [activeEnvironmentId, activeServerThread, activeThreadId, setThreadBranch], | |
| 265 | 271 | ); |
| 266 | 272 | |
| 267 | 273 | const syncThreadBranchAfterGitAction = useCallback( |
| @@ -276,7 +282,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 8 unmodified lines | ||
| 276 | 282 | [persistThreadBranchSync], |
| 277 | 283 | ); |
| 278 | 284 | |
| 279 | − const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd); | |
| 285 | + const { data: gitStatus = null, error: gitStatusError } = useGitStatus({ | |
| 286 | + environmentId: activeEnvironmentId, | |
| 287 | + cwd: gitCwd, | |
| 288 | + }); | |
| 280 | 289 | // Default to true while loading so we don't flash init controls. |
| 281 | 290 | const isRepo = gitStatus?.isRepo ?? true; |
| 282 | 291 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; |
| @@ -287,19 +296,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 4 unmodified lines | ||
| 287 | 296 | const allSelected = excludedFiles.size === 0; |
| 288 | 297 | const noneSelected = selectedFiles.length === 0; |
| 289 | 298 | |
| 290 | − const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); | |
| 299 | + const initMutation = useMutation( | |
| 300 | + gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), | |
| 301 | + ); | |
| 291 | 302 | |
| 292 | 303 | const runImmediateGitActionMutation = useMutation( |
| 293 | 304 | gitRunStackedActionMutationOptions({ |
| 305 | + environmentId: activeEnvironmentId, | |
| 294 | 306 | cwd: gitCwd, |
| 295 | 307 | queryClient, |
| 296 | 308 | }), |
| 297 | 309 | ); |
| 298 | − const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); | |
| 310 | + const pullMutation = useMutation( | |
| 311 | + gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), | |
| 312 | + ); | |
| 299 | 313 | |
| 300 | 314 | const isRunStackedActionRunning = |
| 301 | − useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; | |
| 302 | − const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; | |
| 315 | + useIsMutating({ | |
| 316 | + mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd), | |
| 317 | + }) > 0; | |
| 318 | + const isPullRunning = | |
| 319 | + useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; | |
| 303 | 320 | const isGitActionRunning = isRunStackedActionRunning || isPullRunning; |
| 304 | 321 | |
| 305 | 322 | useEffect(() => { |
| @@ -372,7 +389,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 66 unmodified lines | ||
| 372 | 389 | } |
| 373 | 390 | refreshTimeout = window.setTimeout(() => { |
| 374 | 391 | refreshTimeout = null; |
| 375 | − void refreshGitStatus(gitCwd).catch(() => undefined); | |
| 392 | + void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( | |
| 393 | + () => undefined, | |
| 394 | + ); | |
| 376 | 395 | }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); |
| 377 | 396 | }; |
| 378 | 397 | const handleVisibilityChange = () => { |
| @@ -391,10 +410,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 12 unmodified lines | ||
| 391 | 410 | window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); |
| 392 | 411 | document.removeEventListener("visibilitychange", handleVisibilityChange); |
| 393 | 412 | }; |
| 394 | − }, [gitCwd]); | |
| 413 | + }, [activeEnvironmentId, gitCwd]); | |
| 395 | 414 | |
| 396 | 415 | const openExistingPr = useCallback(async () => { |
| 397 | − const api = readNativeApi(); | |
| 416 | + const api = readLocalApi(); | |
| 398 | 417 | if (!api) { |
| 399 | 418 | toastManager.add({ |
| 400 | 419 | type: "error", |
| @@ -412,7 +431,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 11 unmodified lines | ||
| 412 | 431 | }); |
| 413 | 432 | return; |
| 414 | 433 | } |
| 415 | − void api.shell.openExternal(prUrl).catch((err) => { | |
| 434 | + void api.shell.openExternal(prUrl).catch((err: unknown) => { | |
| 416 | 435 | toastManager.add({ |
| 417 | 436 | type: "error", |
| 418 | 437 | title: "Unable to open PR link", |
| @@ -601,7 +620,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 182 unmodified lines | ||
| 601 | 620 | toastActionProps = { |
| 602 | 621 | children: toastCta.label, |
| 603 | 622 | onClick: () => { |
| 604 | − const api = readNativeApi(); | |
| 623 | + const api = readLocalApi(); | |
| 605 | 624 | if (!api) return; |
| 606 | 625 | closeResultToast(); |
| 607 | 626 | void api.shell.openExternal(toastCta.url); |
| @@ -760,7 +779,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 152 unmodified lines | ||
| 760 | 779 | |
| 761 | 780 | const openChangedFileInEditor = useCallback( |
| 762 | 781 | (filePath: string) => { |
| 763 | − const api = readNativeApi(); | |
| 782 | + const api = readLocalApi(); | |
| 764 | 783 | if (!api || !gitCwd) { |
| 765 | 784 | toastManager.add({ |
| 766 | 785 | type: "error", |
| @@ -836,7 +855,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 69 unmodified lines | ||
| 836 | 855 | <Menu |
| 837 | 856 | onOpenChange={(open) => { |
| 838 | 857 | if (open) { |
| 839 | − void refreshGitStatus(gitCwd).catch(() => undefined); | |
| 858 | + void refreshGitStatus({ | |
| 859 | + environmentId: activeEnvironmentId, | |
| 860 | + cwd: gitCwd, | |
| 861 | + }).catch(() => undefined); | |
| 840 | 862 | } |
| 841 | 863 | }} |
| 842 | 864 | > |
| @@ -2,6 +2,7 @@ import "../index.css"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | DEFAULT_SERVER_SETTINGS, |
| 5 | + EnvironmentId, | |
| 5 | 6 | ORCHESTRATION_WS_METHODS, |
| 6 | 7 | type MessageId, |
| 7 | 8 | type OrchestrationReadModel, |
| @@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } · 10 unmodified lines | ||
| 18 | 19 | import { render } from "vitest-browser-react"; |
| 19 | 20 | |
| 20 | 21 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 21 | −import { __resetNativeApiForTests } from "../nativeApi"; | |
| 22 | +import { __resetLocalApiForTests } from "../localApi"; | |
| 22 | 23 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; |
| 23 | 24 | import { getServerConfig } from "../rpc/serverState"; |
| 24 | 25 | import { getRouter } from "../router"; |
| @@ -34,6 +35,7 @@ vi.mock("../lib/gitStatusState", () => ({ · 9 unmodified lines | ||
| 34 | 35 | |
| 35 | 36 | const THREAD_ID = "thread-kb-toast-test" as ThreadId; |
| 36 | 37 | const PROJECT_ID = "project-1" as ProjectId; |
| 38 | +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); | |
| 37 | 39 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
| 38 | 40 | |
| 39 | 41 | interface TestFixture { |
| @@ -49,6 +51,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); · 9 unmodified lines | ||
| 49 | 51 | |
| 50 | 52 | function createBaseServerConfig(): ServerConfig { |
| 51 | 53 | return { |
| 54 | + environment: { | |
| 55 | + environmentId: LOCAL_ENVIRONMENT_ID, | |
| 56 | + label: "Local environment", | |
| 57 | + platform: { os: "darwin" as const, arch: "arm64" as const }, | |
| 58 | + serverVersion: "0.0.0-test", | |
| 59 | + capabilities: { repositoryIdentity: true }, | |
| 60 | + }, | |
| 52 | 61 | cwd: "/repo/project", |
| 53 | 62 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 54 | 63 | keybindings: [], |
| @@ -155,6 +164,13 @@ function buildFixture(): TestFixture { · 100 unmodified lines | ||
| 155 | 164 | snapshot: createMinimalSnapshot(), |
| 156 | 165 | serverConfig: createBaseServerConfig(), |
| 157 | 166 | welcome: { |
| 167 | + environment: { | |
| 168 | + environmentId: LOCAL_ENVIRONMENT_ID, | |
| 169 | + label: "Local environment", | |
| 170 | + platform: { os: "darwin" as const, arch: "arm64" as const }, | |
| 171 | + serverVersion: "0.0.0-test", | |
| 172 | + capabilities: { repositoryIdentity: true }, | |
| 173 | + }, | |
| 158 | 174 | cwd: "/repo/project", |
| 159 | 175 | projectName: "Project", |
| 160 | 176 | bootstrapProjectId: PROJECT_ID, |
| @@ -286,7 +302,9 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { · 125 unmodified lines | ||
| 286 | 302 | host.style.overflow = "hidden"; |
| 287 | 303 | document.body.append(host); |
| 288 | 304 | |
| 289 | − const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); | |
| 305 | + const router = getRouter( | |
| 306 | + createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }), | |
| 307 | + ); | |
| 290 | 308 | |
| 291 | 309 | const screen = await render( |
| 292 | 310 | <AppAtomRegistryProvider> |
| @@ -347,32 +365,17 @@ describe("Keybindings update toast", () => { · 54 unmodified lines | ||
| 347 | 365 | return []; |
| 348 | 366 | }, |
| 349 | 367 | }); |
| 350 | − await __resetNativeApiForTests(); | |
| 368 | + await __resetLocalApiForTests(); | |
| 351 | 369 | localStorage.clear(); |
| 352 | 370 | document.body.innerHTML = ""; |
| 353 | 371 | useComposerDraftStore.setState({ |
| 354 | − draftsByThreadId: {}, | |
| 355 | − draftThreadsByThreadId: {}, | |
| 356 | − projectDraftThreadIdByProjectId: {}, | |
| 372 | + draftsByThreadKey: {}, | |
| 373 | + draftThreadsByThreadKey: {}, | |
| 374 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 357 | 375 | }); |
| 358 | 376 | useStore.setState({ |
| 359 | − projectIds: [], | |
| 360 | − projectById: {}, | |
| 361 | − threadIds: [], | |
| 362 | − threadIdsByProjectId: {}, | |
| 363 | − threadShellById: {}, | |
| 364 | − threadSessionById: {}, | |
| 365 | − threadTurnStateById: {}, | |
| 366 | − messageIdsByThreadId: {}, | |
| 367 | − messageByThreadId: {}, | |
| 368 | − activityIdsByThreadId: {}, | |
| 369 | − activityByThreadId: {}, | |
| 370 | − proposedPlanIdsByThreadId: {}, | |
| 371 | − proposedPlanByThreadId: {}, | |
| 372 | − turnDiffIdsByThreadId: {}, | |
| 373 | − turnDiffSummaryByThreadId: {}, | |
| 374 | − sidebarThreadSummaryById: {}, | |
| 375 | − bootstrapComplete: false, | |
| 377 | + activeEnvironmentId: null, | |
| 378 | + environmentStateById: {}, | |
| 376 | 379 | }); |
| 377 | 380 | }); |
| 378 | 381 | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { memo, useState, useCallback } from "react"; |
| 2 | +import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 3 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 3 | 4 | import { Badge } from "./ui/badge"; |
| 4 | 5 | import { Button } from "./ui/button"; |
| @@ -24,7 +25,7 @@ import { · 19 unmodified lines | ||
| 24 | 25 | stripDisplayedPlanMarkdown, |
| 25 | 26 | } from "../proposedPlan"; |
| 26 | 27 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; |
| 27 | −import { readNativeApi } from "~/nativeApi"; | |
| 28 | +import { readEnvironmentApi } from "~/environmentApi"; | |
| 28 | 29 | import { toastManager } from "./ui/toast"; |
| 29 | 30 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 30 | 31 | |
| @@ -53,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode { · 22 unmodified lines | ||
| 53 | 54 | interface PlanSidebarProps { |
| 54 | 55 | activePlan: ActivePlanState | null; |
| 55 | 56 | activeProposedPlan: LatestProposedPlanState | null; |
| 57 | + environmentId: EnvironmentId; | |
| 56 | 58 | markdownCwd: string | undefined; |
| 57 | 59 | workspaceRoot: string | undefined; |
| 58 | 60 | timestampFormat: TimestampFormat; |
| @@ -62,6 +64,7 @@ interface PlanSidebarProps { · 3 unmodified lines | ||
| 62 | 64 | const PlanSidebar = memo(function PlanSidebar({ |
| 63 | 65 | activePlan, |
| 64 | 66 | activeProposedPlan, |
| 67 | + environmentId, | |
| 65 | 68 | markdownCwd, |
| 66 | 69 | workspaceRoot, |
| 67 | 70 | timestampFormat, |
| @@ -87,7 +90,7 @@ const PlanSidebar = memo(function PlanSidebar({ · 19 unmodified lines | ||
| 87 | 90 | }, [planMarkdown]); |
| 88 | 91 | |
| 89 | 92 | const handleSaveToWorkspace = useCallback(() => { |
| 90 | − const api = readNativeApi(); | |
| 93 | + const api = readEnvironmentApi(environmentId); | |
| 91 | 94 | if (!api || !workspaceRoot || !planMarkdown) return; |
| 92 | 95 | const filename = buildProposedPlanMarkdownFilename(planMarkdown); |
| 93 | 96 | setIsSavingToWorkspace(true); |
| @@ -115,7 +118,7 @@ const PlanSidebar = memo(function PlanSidebar({ · 21 unmodified lines | ||
| 115 | 118 | () => setIsSavingToWorkspace(false), |
| 116 | 119 | () => setIsSavingToWorkspace(false), |
| 117 | 120 | ); |
| 118 | − }, [planMarkdown, workspaceRoot]); | |
| 121 | + }, [environmentId, planMarkdown, workspaceRoot]); | |
| 119 | 122 | |
| 120 | 123 | return ( |
| 121 | 124 | <div className="flex h-full w-[340px] shrink-0 flex-col border-l border-border/70 bg-card/50"> |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import type { GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; | |
| 1 | +import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 4 | 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| @@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner"; · 19 unmodified lines | ||
| 24 | 24 | |
| 25 | 25 | interface PullRequestThreadDialogProps { |
| 26 | 26 | open: boolean; |
| 27 | + environmentId: EnvironmentId; | |
| 27 | 28 | threadId: ThreadId; |
| 28 | 29 | cwd: string | null; |
| 29 | 30 | initialReference: string | null; |
| @@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps { · 3 unmodified lines | ||
| 33 | 34 | |
| 34 | 35 | export function PullRequestThreadDialog({ |
| 35 | 36 | open, |
| 37 | + environmentId, | |
| 36 | 38 | threadId, |
| 37 | 39 | cwd, |
| 38 | 40 | initialReference, |
| @@ -72,6 +74,7 @@ export function PullRequestThreadDialog({ · 33 unmodified lines | ||
| 72 | 74 | const parsedDebouncedReference = parsePullRequestReference(debouncedReference); |
| 73 | 75 | const resolvePullRequestQuery = useQuery( |
| 74 | 76 | gitResolvePullRequestQueryOptions({ |
| 77 | + environmentId, | |
| 75 | 78 | cwd, |
| 76 | 79 | reference: open ? parsedDebouncedReference : null, |
| 77 | 80 | }), |
| @@ -83,13 +86,14 @@ export function PullRequestThreadDialog({ · 5 unmodified lines | ||
| 83 | 86 | const cached = queryClient.getQueryData<GitResolvePullRequestResult>([ |
| 84 | 87 | "git", |
| 85 | 88 | "pull-request", |
| 89 | + environmentId, | |
| 86 | 90 | cwd, |
| 87 | 91 | parsedReference, |
| 88 | 92 | ]); |
| 89 | 93 | return cached?.pullRequest ?? null; |
| 90 | − }, [cwd, parsedReference, queryClient]); | |
| 94 | + }, [cwd, environmentId, parsedReference, queryClient]); | |
| 91 | 95 | const preparePullRequestThreadMutation = useMutation( |
| 92 | − gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }), | |
| 96 | + gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }), | |
| 93 | 97 | ); |
| 94 | 98 | |
| 95 | 99 | const liveResolvedPullRequest = |
| @@ -20,7 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | sortThreadsForSidebar, |
| 21 | 21 | THREAD_JUMP_HINT_SHOW_DELAY_MS, |
| 22 | 22 | } from "./Sidebar.logic"; |
| 23 | −import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 23 | +import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 24 | 24 | import { |
| 25 | 25 | DEFAULT_INTERACTION_MODE, |
| 26 | 26 | DEFAULT_RUNTIME_MODE, |
| @@ -28,6 +28,8 @@ import { · 1 unmodified line | ||
| 28 | 28 | type Thread, |
| 29 | 29 | } from "../types"; |
| 30 | 30 | |
| 31 | +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); | |
| 32 | + | |
| 31 | 33 | function makeLatestTurn(overrides?: { |
| 32 | 34 | completedAt?: string | null; |
| 33 | 35 | startedAt?: string | null; |
| @@ -625,6 +627,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { · 591 unmodified lines | ||
| 625 | 627 | const { defaultModelSelection, ...rest } = overrides; |
| 626 | 628 | return { |
| 627 | 629 | id: ProjectId.makeUnsafe("project-1"), |
| 630 | + environmentId: localEnvironmentId, | |
| 628 | 631 | name: "Project", |
| 629 | 632 | cwd: "/tmp/project", |
| 630 | 633 | defaultModelSelection: { |
| @@ -642,6 +645,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { · 11 unmodified lines | ||
| 642 | 645 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 643 | 646 | return { |
| 644 | 647 | id: ThreadId.makeUnsafe("thread-1"), |
| 648 | + environmentId: localEnvironmentId, | |
| 645 | 649 | codexThreadId: null, |
| 646 | 650 | projectId: ProjectId.makeUnsafe("project-1"), |
| 647 | 651 | title: "Thread", |
| @@ -12,20 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "lucide-react"; |
| 13 | 13 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 14 | 14 | import { autoAnimate } from "@formkit/auto-animate"; |
| 15 | −import { | |
| 16 | − useCallback, | |
| 17 | − useEffect, | |
| 18 | − useMemo, | |
| 19 | − useRef, | |
| 20 | − useState, | |
| 21 | − type Dispatch, | |
| 22 | − type KeyboardEvent, | |
| 23 | − type MouseEvent, | |
| 24 | − type MutableRefObject, | |
| 25 | − type PointerEvent, | |
| 26 | − type ReactNode, | |
| 27 | − type SetStateAction, | |
| 28 | −} from "react"; | |
| 15 | +import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; | |
| 29 | 16 | import { useShallow } from "zustand/react/shallow"; |
| 30 | 17 | import { |
| 31 | 18 | DndContext, |
| @@ -45,11 +32,20 @@ import { CSS } from "@dnd-kit/utilities"; · 13 unmodified lines | ||
| 45 | 32 | import { |
| 46 | 33 | DEFAULT_MODEL_BY_PROVIDER, |
| 47 | 34 | type DesktopUpdateState, |
| 35 | + type EnvironmentId, | |
| 48 | 36 | ProjectId, |
| 37 | + type ScopedThreadRef, | |
| 38 | + type ThreadEnvMode, | |
| 49 | 39 | ThreadId, |
| 50 | 40 | type GitStatusResult, |
| 51 | 41 | } from "@t3tools/contracts"; |
| 52 | −import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; | |
| 42 | +import { | |
| 43 | + scopedProjectKey, | |
| 44 | + scopedThreadKey, | |
| 45 | + scopeProjectRef, | |
| 46 | + scopeThreadRef, | |
| 47 | +} from "@t3tools/client-runtime"; | |
| 48 | +import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; | |
| 53 | 49 | import { |
| 54 | 50 | type SidebarProjectSortOrder, |
| 55 | 51 | type SidebarThreadSortOrder, |
| @@ -58,7 +54,13 @@ import { isElectron } from "../env"; · 2 unmodified lines | ||
| 58 | 54 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 59 | 55 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 60 | 56 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; |
| 61 | −import { useStore } from "../store"; | |
| 57 | +import { | |
| 58 | + selectProjectsAcrossEnvironments, | |
| 59 | + selectSidebarThreadsForProjectRef, | |
| 60 | + selectSidebarThreadsAcrossEnvironments, | |
| 61 | + selectThreadByRef, | |
| 62 | + useStore, | |
| 63 | +} from "../store"; | |
| 62 | 64 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 63 | 65 | import { useUiStateStore } from "../uiStateStore"; |
| 64 | 66 | import { |
| @@ -70,11 +72,16 @@ import { · 5 unmodified lines | ||
| 70 | 72 | threadTraversalDirectionFromCommand, |
| 71 | 73 | } from "../keybindings"; |
| 72 | 74 | import { useGitStatus } from "../lib/gitStatusState"; |
| 73 | −import { readNativeApi } from "../nativeApi"; | |
| 75 | +import { readLocalApi } from "../localApi"; | |
| 74 | 76 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 75 | 77 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 76 | 78 | |
| 77 | 79 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 80 | +import { | |
| 81 | + buildThreadRouteParams, | |
| 82 | + resolveThreadRouteRef, | |
| 83 | + resolveThreadRouteTarget, | |
| 84 | +} from "../threadRoutes"; | |
| 78 | 85 | import { toastManager } from "./ui/toast"; |
| 79 | 86 | import { formatRelativeTimeLabel } from "../timestampFormat"; |
| 80 | 87 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| @@ -109,8 +116,6 @@ import { · 28 unmodified lines | ||
| 109 | 116 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 110 | 117 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 111 | 118 | import { |
| 112 | − getVisibleSidebarThreadIds, | |
| 113 | − getVisibleThreadsForProject, | |
| 114 | 119 | resolveAdjacentThreadId, |
| 115 | 120 | isContextMenuPointerDown, |
| 116 | 121 | resolveProjectStatusIndicator, |
| @@ -123,12 +128,14 @@ import { · 6 unmodified lines | ||
| 123 | 128 | sortProjectsForSidebar, |
| 124 | 129 | sortThreadsForSidebar, |
| 125 | 130 | useThreadJumpHintVisibility, |
| 131 | + ThreadStatusPill, | |
| 126 | 132 | } from "./Sidebar.logic"; |
| 127 | 133 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; |
| 128 | 134 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 135 | +import { readEnvironmentApi } from "../environmentApi"; | |
| 129 | 136 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 130 | 137 | import { useServerKeybindings } from "../rpc/serverState"; |
| 131 | −import type { Project } from "../types"; | |
| 138 | +import type { Project, SidebarThreadSummary } from "../types"; | |
| 132 | 139 | const THREAD_PREVIEW_LIMIT = 6; |
| 133 | 140 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| 134 | 141 | updated_at: "Last user message", |
| @@ -143,9 +150,58 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { · 8 unmodified lines | ||
| 143 | 150 | duration: 180, |
| 144 | 151 | easing: "ease-out", |
| 145 | 152 | } as const; |
| 153 | +const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>(); | |
| 154 | + | |
| 155 | +function threadJumpLabelMapsEqual( | |
| 156 | + left: ReadonlyMap<string, string>, | |
| 157 | + right: ReadonlyMap<string, string>, | |
| 158 | +): boolean { | |
| 159 | + if (left === right) { | |
| 160 | + return true; | |
| 161 | + } | |
| 162 | + if (left.size !== right.size) { | |
| 163 | + return false; | |
| 164 | + } | |
| 165 | + for (const [key, value] of left) { | |
| 166 | + if (right.get(key) !== value) { | |
| 167 | + return false; | |
| 168 | + } | |
| 169 | + } | |
| 170 | + return true; | |
| 171 | +} | |
| 172 | + | |
| 173 | +function buildThreadJumpLabelMap(input: { | |
| 174 | + keybindings: ReturnType<typeof useServerKeybindings>; | |
| 175 | + platform: string; | |
| 176 | + terminalOpen: boolean; | |
| 177 | + threadJumpCommandByKey: ReadonlyMap< | |
| 178 | + string, | |
| 179 | + NonNullable<ReturnType<typeof threadJumpCommandForIndex>> | |
| 180 | + >; | |
| 181 | +}): ReadonlyMap<string, string> { | |
| 182 | + if (input.threadJumpCommandByKey.size === 0) { | |
| 183 | + return EMPTY_THREAD_JUMP_LABELS; | |
| 184 | + } | |
| 185 | + | |
| 186 | + const shortcutLabelOptions = { | |
| 187 | + platform: input.platform, | |
| 188 | + context: { | |
| 189 | + terminalFocus: false, | |
| 190 | + terminalOpen: input.terminalOpen, | |
| 191 | + }, | |
| 192 | + } as const; | |
| 193 | + const mapping = new Map<string, string>(); | |
| 194 | + for (const [threadKey, command] of input.threadJumpCommandByKey) { | |
| 195 | + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); | |
| 196 | + if (label) { | |
| 197 | + mapping.set(threadKey, label); | |
| 198 | + } | |
| 199 | + } | |
| 200 | + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; | |
| 201 | +} | |
| 146 | 202 | |
| 147 | 203 | type SidebarProjectSnapshot = Project & { |
| 148 | − expanded: boolean; | |
| 204 | + projectKey: string; | |
| 149 | 205 | }; |
| 150 | 206 | interface TerminalStatusIndicator { |
| 151 | 207 | label: "Terminal process running"; |
| @@ -166,7 +222,7 @@ function ThreadStatusLabel({ · 14 unmodified lines | ||
| 166 | 222 | status, |
| 167 | 223 | compact = false, |
| 168 | 224 | }: { |
| 169 | − status: NonNullable<ReturnType<typeof resolveThreadStatusPill>>; | |
| 225 | + status: ThreadStatusPill; | |
| 170 | 226 | compact?: boolean; |
| 171 | 227 | }) { |
| 172 | 228 | if (compact) { |
| @@ -255,57 +311,81 @@ function resolveThreadPr( · 82 unmodified lines | ||
| 255 | 311 | } |
| 256 | 312 | |
| 257 | 313 | interface SidebarThreadRowProps { |
| 258 | − threadId: ThreadId; | |
| 314 | + thread: SidebarThreadSummary; | |
| 259 | 315 | projectCwd: string | null; |
| 260 | − orderedProjectThreadIds: readonly ThreadId[]; | |
| 261 | − routeThreadId: ThreadId | null; | |
| 262 | − selectedThreadIds: ReadonlySet<ThreadId>; | |
| 263 | − showThreadJumpHints: boolean; | |
| 316 | + orderedProjectThreadKeys: readonly string[]; | |
| 317 | + isActive: boolean; | |
| 264 | 318 | jumpLabel: string | null; |
| 265 | 319 | appSettingsConfirmThreadArchive: boolean; |
| 266 | − renamingThreadId: ThreadId | null; | |
| 320 | + renamingThreadKey: string | null; | |
| 267 | 321 | renamingTitle: string; |
| 268 | 322 | setRenamingTitle: (title: string) => void; |
| 269 | − onRenamingInputMount: (element: HTMLInputElement | null) => void; | |
| 270 | − hasRenameCommitted: () => boolean; | |
| 271 | − markRenameCommitted: () => void; | |
| 272 | − confirmingArchiveThreadId: ThreadId | null; | |
| 273 | − setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>; | |
| 274 | − confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>; | |
| 323 | + renamingInputRef: React.RefObject<HTMLInputElement | null>; | |
| 324 | + renamingCommittedRef: React.RefObject<boolean>; | |
| 325 | + confirmingArchiveThreadKey: string | null; | |
| 326 | + setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>; | |
| 327 | + confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>; | |
| 275 | 328 | handleThreadClick: ( |
| 276 | − event: MouseEvent, | |
| 277 | − threadId: ThreadId, | |
| 278 | − orderedProjectThreadIds: readonly ThreadId[], | |
| 329 | + event: React.MouseEvent, | |
| 330 | + threadRef: ScopedThreadRef, | |
| 331 | + orderedProjectThreadKeys: readonly string[], | |
| 279 | 332 | ) => void; |
| 280 | − navigateToThread: (threadId: ThreadId) => void; | |
| 333 | + navigateToThread: (threadRef: ScopedThreadRef) => void; | |
| 281 | 334 | handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; |
| 282 | 335 | handleThreadContextMenu: ( |
| 283 | − threadId: ThreadId, | |
| 336 | + threadRef: ScopedThreadRef, | |
| 284 | 337 | position: { x: number; y: number }, |
| 285 | 338 | ) => Promise<void>; |
| 286 | 339 | clearSelection: () => void; |
| 287 | − commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise<void>; | |
| 340 | + commitRename: ( | |
| 341 | + threadRef: ScopedThreadRef, | |
| 342 | + newTitle: string, | |
| 343 | + originalTitle: string, | |
| 344 | + ) => Promise<void>; | |
| 288 | 345 | cancelRename: () => void; |
| 289 | − attemptArchiveThread: (threadId: ThreadId) => Promise<void>; | |
| 290 | − openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void; | |
| 346 | + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>; | |
| 347 | + openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void; | |
| 291 | 348 | } |
| 292 | 349 | |
| 293 | −function SidebarThreadRow(props: SidebarThreadRowProps) { | |
| 294 | − const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); | |
| 295 | − const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); | |
| 350 | +const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { | |
| 351 | + const { | |
| 352 | + orderedProjectThreadKeys, | |
| 353 | + isActive, | |
| 354 | + jumpLabel, | |
| 355 | + appSettingsConfirmThreadArchive, | |
| 356 | + renamingThreadKey, | |
| 357 | + renamingTitle, | |
| 358 | + setRenamingTitle, | |
| 359 | + renamingInputRef, | |
| 360 | + renamingCommittedRef, | |
| 361 | + confirmingArchiveThreadKey, | |
| 362 | + setConfirmingArchiveThreadKey, | |
| 363 | + confirmArchiveButtonRefs, | |
| 364 | + handleThreadClick, | |
| 365 | + navigateToThread, | |
| 366 | + handleMultiSelectContextMenu, | |
| 367 | + handleThreadContextMenu, | |
| 368 | + clearSelection, | |
| 369 | + commitRename, | |
| 370 | + cancelRename, | |
| 371 | + attemptArchiveThread, | |
| 372 | + openPrLink, | |
| 373 | + thread, | |
| 374 | + } = props; | |
| 375 | + const threadRef = scopeThreadRef(thread.environmentId, thread.id); | |
| 376 | + const threadKey = scopedThreadKey(threadRef); | |
| 377 | + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); | |
| 378 | + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); | |
| 379 | + const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); | |
| 296 | 380 | const runningTerminalIds = useTerminalStateStore( |
| 297 | 381 | (state) => |
| 298 | − selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, | |
| 382 | + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, | |
| 299 | 383 | ); |
| 300 | − const gitCwd = thread?.worktreePath ?? props.projectCwd; | |
| 301 | − const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); | |
| 302 | − | |
| 303 | − if (!thread) { | |
| 304 | − return null; | |
| 305 | − } | |
| 306 | − | |
| 307 | − const isActive = props.routeThreadId === thread.id; | |
| 308 | − const isSelected = props.selectedThreadIds.has(thread.id); | |
| 384 | + const gitCwd = thread.worktreePath ?? props.projectCwd; | |
| 385 | + const gitStatus = useGitStatus({ | |
| 386 | + environmentId: thread.environmentId, | |
| 387 | + cwd: thread.branch != null ? gitCwd : null, | |
| 388 | + }); | |
| 309 | 389 | const isHighlighted = isActive || isSelected; |
| 310 | 390 | const isThreadRunning = |
| 311 | 391 | thread.session?.status === "running" && thread.session.activeTurnId != null; |
| @@ -318,32 +398,173 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 6 unmodified lines | ||
| 318 | 398 | const pr = resolveThreadPr(thread.branch, gitStatus.data); |
| 319 | 399 | const prStatus = prStatusIndicator(pr); |
| 320 | 400 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 321 | − const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; | |
| 401 | + const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; | |
| 322 | 402 | const threadMetaClassName = isConfirmingArchive |
| 323 | 403 | ? "pointer-events-none opacity-0" |
| 324 | 404 | : !isThreadRunning |
| 325 | 405 | ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" |
| 326 | 406 | : "pointer-events-none"; |
| 407 | + const clearConfirmingArchive = useCallback(() => { | |
| 408 | + setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); | |
| 409 | + }, [setConfirmingArchiveThreadKey, threadKey]); | |
| 410 | + const handleMouseLeave = useCallback(() => { | |
| 411 | + clearConfirmingArchive(); | |
| 412 | + }, [clearConfirmingArchive]); | |
| 413 | + const handleBlurCapture = useCallback( | |
| 414 | + (event: React.FocusEvent<HTMLLIElement>) => { | |
| 415 | + const currentTarget = event.currentTarget; | |
| 416 | + requestAnimationFrame(() => { | |
| 417 | + if (currentTarget.contains(document.activeElement)) { | |
| 418 | + return; | |
| 419 | + } | |
| 420 | + clearConfirmingArchive(); | |
| 421 | + }); | |
| 422 | + }, | |
| 423 | + [clearConfirmingArchive], | |
| 424 | + ); | |
| 425 | + const handleRowClick = useCallback( | |
| 426 | + (event: React.MouseEvent) => { | |
| 427 | + handleThreadClick(event, threadRef, orderedProjectThreadKeys); | |
| 428 | + }, | |
| 429 | + [handleThreadClick, orderedProjectThreadKeys, threadRef], | |
| 430 | + ); | |
| 431 | + const handleRowKeyDown = useCallback( | |
| 432 | + (event: React.KeyboardEvent) => { | |
| 433 | + if (event.key !== "Enter" && event.key !== " ") return; | |
| 434 | + event.preventDefault(); | |
| 435 | + navigateToThread(threadRef); | |
| 436 | + }, | |
| 437 | + [navigateToThread, threadRef], | |
| 438 | + ); | |
| 439 | + const handleRowContextMenu = useCallback( | |
| 440 | + (event: React.MouseEvent) => { | |
| 441 | + event.preventDefault(); | |
| 442 | + if (hasSelection && isSelected) { | |
| 443 | + void handleMultiSelectContextMenu({ | |
| 444 | + x: event.clientX, | |
| 445 | + y: event.clientY, | |
| 446 | + }); | |
| 447 | + return; | |
| 448 | + } | |
| 449 | + | |
| 450 | + if (hasSelection) { | |
| 451 | + clearSelection(); | |
| 452 | + } | |
| 453 | + void handleThreadContextMenu(threadRef, { | |
| 454 | + x: event.clientX, | |
| 455 | + y: event.clientY, | |
| 456 | + }); | |
| 457 | + }, | |
| 458 | + [ | |
| 459 | + clearSelection, | |
| 460 | + handleMultiSelectContextMenu, | |
| 461 | + handleThreadContextMenu, | |
| 462 | + hasSelection, | |
| 463 | + isSelected, | |
| 464 | + threadRef, | |
| 465 | + ], | |
| 466 | + ); | |
| 467 | + const handlePrClick = useCallback( | |
| 468 | + (event: React.MouseEvent<HTMLButtonElement>) => { | |
| 469 | + if (!prStatus) return; | |
| 470 | + openPrLink(event, prStatus.url); | |
| 471 | + }, | |
| 472 | + [openPrLink, prStatus], | |
| 473 | + ); | |
| 474 | + const handleRenameInputRef = useCallback( | |
| 475 | + (element: HTMLInputElement | null) => { | |
| 476 | + if (element && renamingInputRef.current !== element) { | |
| 477 | + renamingInputRef.current = element; | |
| 478 | + element.focus(); | |
| 479 | + element.select(); | |
| 480 | + } | |
| 481 | + }, | |
| 482 | + [renamingInputRef], | |
| 483 | + ); | |
| 484 | + const handleRenameInputChange = useCallback( | |
| 485 | + (event: React.ChangeEvent<HTMLInputElement>) => { | |
| 486 | + setRenamingTitle(event.target.value); | |
| 487 | + }, | |
| 488 | + [setRenamingTitle], | |
| 489 | + ); | |
| 490 | + const handleRenameInputKeyDown = useCallback( | |
| 491 | + (event: React.KeyboardEvent<HTMLInputElement>) => { | |
| 492 | + event.stopPropagation(); | |
| 493 | + if (event.key === "Enter") { | |
| 494 | + event.preventDefault(); | |
| 495 | + renamingCommittedRef.current = true; | |
| 496 | + void commitRename(threadRef, renamingTitle, thread.title); | |
| 497 | + } else if (event.key === "Escape") { | |
| 498 | + event.preventDefault(); | |
| 499 | + renamingCommittedRef.current = true; | |
| 500 | + cancelRename(); | |
| 501 | + } | |
| 502 | + }, | |
| 503 | + [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], | |
| 504 | + ); | |
| 505 | + const handleRenameInputBlur = useCallback(() => { | |
| 506 | + if (!renamingCommittedRef.current) { | |
| 507 | + void commitRename(threadRef, renamingTitle, thread.title); | |
| 508 | + } | |
| 509 | + }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); | |
| 510 | + const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => { | |
| 511 | + event.stopPropagation(); | |
| 512 | + }, []); | |
| 513 | + const handleConfirmArchiveRef = useCallback( | |
| 514 | + (element: HTMLButtonElement | null) => { | |
| 515 | + if (element) { | |
| 516 | + confirmArchiveButtonRefs.current.set(threadKey, element); | |
| 517 | + } else { | |
| 518 | + confirmArchiveButtonRefs.current.delete(threadKey); | |
| 519 | + } | |
| 520 | + }, | |
| 521 | + [confirmArchiveButtonRefs, threadKey], | |
| 522 | + ); | |
| 523 | + const stopPropagationOnPointerDown = useCallback( | |
| 524 | + (event: React.PointerEvent<HTMLButtonElement>) => { | |
| 525 | + event.stopPropagation(); | |
| 526 | + }, | |
| 527 | + [], | |
| 528 | + ); | |
| 529 | + const handleConfirmArchiveClick = useCallback( | |
| 530 | + (event: React.MouseEvent<HTMLButtonElement>) => { | |
| 531 | + event.preventDefault(); | |
| 532 | + event.stopPropagation(); | |
| 533 | + clearConfirmingArchive(); | |
| 534 | + void attemptArchiveThread(threadRef); | |
| 535 | + }, | |
| 536 | + [attemptArchiveThread, clearConfirmingArchive, threadRef], | |
| 537 | + ); | |
| 538 | + const handleStartArchiveConfirmation = useCallback( | |
| 539 | + (event: React.MouseEvent<HTMLButtonElement>) => { | |
| 540 | + event.preventDefault(); | |
| 541 | + event.stopPropagation(); | |
| 542 | + setConfirmingArchiveThreadKey(threadKey); | |
| 543 | + requestAnimationFrame(() => { | |
| 544 | + confirmArchiveButtonRefs.current.get(threadKey)?.focus(); | |
| 545 | + }); | |
| 546 | + }, | |
| 547 | + [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], | |
| 548 | + ); | |
| 549 | + const handleArchiveImmediateClick = useCallback( | |
| 550 | + (event: React.MouseEvent<HTMLButtonElement>) => { | |
| 551 | + event.preventDefault(); | |
| 552 | + event.stopPropagation(); | |
| 553 | + void attemptArchiveThread(threadRef); | |
| 554 | + }, | |
| 555 | + [attemptArchiveThread, threadRef], | |
| 556 | + ); | |
| 557 | + const rowButtonRender = useMemo(() => <div role="button" tabIndex={0} />, []); | |
| 327 | 558 | |
| 328 | 559 | return ( |
| 329 | 560 | <SidebarMenuSubItem |
| 330 | 561 | className="w-full" |
| 331 | 562 | data-thread-item |
| 332 | − onMouseLeave={() => { | |
| 333 | − props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); | |
| 334 | − }} | |
| 335 | − onBlurCapture={(event) => { | |
| 336 | − const currentTarget = event.currentTarget; | |
| 337 | − requestAnimationFrame(() => { | |
| 338 | − if (currentTarget.contains(document.activeElement)) { | |
| 339 | − return; | |
| 340 | − } | |
| 341 | − props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); | |
| 342 | − }); | |
| 343 | − }} | |
| 563 | + onMouseLeave={handleMouseLeave} | |
| 564 | + onBlurCapture={handleBlurCapture} | |
| 344 | 565 | > |
| 345 | 566 | <SidebarMenuSubButton |
| 346 | − render={<div role="button" tabIndex={0} />} | |
| 567 | + render={rowButtonRender} | |
| 347 | 568 | size="sm" |
| 348 | 569 | isActive={isActive} |
| 349 | 570 | data-testid={`thread-row-${thread.id}`} |
| @@ -351,31 +572,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 1 unmodified line | ||
| 351 | 572 | isActive, |
| 352 | 573 | isSelected, |
| 353 | 574 | })} relative isolate`} |
| 354 | − onClick={(event) => { | |
| 355 | − props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds); | |
| 356 | − }} | |
| 357 | − onKeyDown={(event) => { | |
| 358 | − if (event.key !== "Enter" && event.key !== " ") return; | |
| 359 | − event.preventDefault(); | |
| 360 | − props.navigateToThread(thread.id); | |
| 361 | − }} | |
| 362 | − onContextMenu={(event) => { | |
| 363 | − event.preventDefault(); | |
| 364 | − if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) { | |
| 365 | − void props.handleMultiSelectContextMenu({ | |
| 366 | − x: event.clientX, | |
| 367 | − y: event.clientY, | |
| 368 | − }); | |
| 369 | − } else { | |
| 370 | − if (props.selectedThreadIds.size > 0) { | |
| 371 | − props.clearSelection(); | |
| 372 | − } | |
| 373 | − void props.handleThreadContextMenu(thread.id, { | |
| 374 | − x: event.clientX, | |
| 375 | − y: event.clientY, | |
| 376 | − }); | |
| 377 | − } | |
| 378 | − }} | |
| 575 | + onClick={handleRowClick} | |
| 576 | + onKeyDown={handleRowKeyDown} | |
| 577 | + onContextMenu={handleRowContextMenu} | |
| 379 | 578 | > |
| 380 | 579 | <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left"> |
| 381 | 580 | {prStatus && ( |
| @@ -386,9 +585,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 4 unmodified lines | ||
| 386 | 585 | type="button" |
| 387 | 586 | aria-label={prStatus.tooltip} |
| 388 | 587 | className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} |
| 389 | − onClick={(event) => { | |
| 390 | − props.openPrLink(event, prStatus.url); | |
| 391 | − }} | |
| 588 | + onClick={handlePrClick} | |
| 392 | 589 | > |
| 393 | 590 | <GitPullRequestIcon className="size-3" /> |
| 394 | 591 | </button> |
| @@ -398,30 +595,15 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 3 unmodified lines | ||
| 398 | 595 | </Tooltip> |
| 399 | 596 | )} |
| 400 | 597 | {threadStatus && <ThreadStatusLabel status={threadStatus} />} |
| 401 | − {props.renamingThreadId === thread.id ? ( | |
| 598 | + {renamingThreadKey === threadKey ? ( | |
| 402 | 599 | <input |
| 403 | − ref={props.onRenamingInputMount} | |
| 600 | + ref={handleRenameInputRef} | |
| 404 | 601 | className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" |
| 405 | − value={props.renamingTitle} | |
| 406 | − onChange={(event) => props.setRenamingTitle(event.target.value)} | |
| 407 | − onKeyDown={(event) => { | |
| 408 | − event.stopPropagation(); | |
| 409 | − if (event.key === "Enter") { | |
| 410 | − event.preventDefault(); | |
| 411 | − props.markRenameCommitted(); | |
| 412 | − void props.commitRename(thread.id, props.renamingTitle, thread.title); | |
| 413 | − } else if (event.key === "Escape") { | |
| 414 | − event.preventDefault(); | |
| 415 | − props.markRenameCommitted(); | |
| 416 | − props.cancelRename(); | |
| 417 | − } | |
| 418 | − }} | |
| 419 | − onBlur={() => { | |
| 420 | − if (!props.hasRenameCommitted()) { | |
| 421 | − void props.commitRename(thread.id, props.renamingTitle, thread.title); | |
| 422 | − } | |
| 423 | − }} | |
| 424 | − onClick={(event) => event.stopPropagation()} | |
| 602 | + value={renamingTitle} | |
| 603 | + onChange={handleRenameInputChange} | |
| 604 | + onKeyDown={handleRenameInputKeyDown} | |
| 605 | + onBlur={handleRenameInputBlur} | |
| 606 | + onClick={handleRenameInputClick} | |
| 425 | 607 | /> |
| 426 | 608 | ) : ( |
| 427 | 609 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> |
| @@ -441,34 +623,19 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 13 unmodified lines | ||
| 441 | 623 | <div className="flex min-w-12 justify-end"> |
| 442 | 624 | {isConfirmingArchive ? ( |
| 443 | 625 | <button |
| 444 | − ref={(element) => { | |
| 445 | − if (element) { | |
| 446 | − props.confirmArchiveButtonRefs.current.set(thread.id, element); | |
| 447 | − } else { | |
| 448 | − props.confirmArchiveButtonRefs.current.delete(thread.id); | |
| 449 | − } | |
| 450 | − }} | |
| 626 | + ref={handleConfirmArchiveRef} | |
| 451 | 627 | type="button" |
| 452 | 628 | data-thread-selection-safe |
| 453 | 629 | data-testid={`thread-archive-confirm-${thread.id}`} |
| 454 | 630 | aria-label={`Confirm archive ${thread.title}`} |
| 455 | 631 | className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40" |
| 456 | − onPointerDown={(event) => { | |
| 457 | − event.stopPropagation(); | |
| 458 | − }} | |
| 459 | − onClick={(event) => { | |
| 460 | − event.preventDefault(); | |
| 461 | − event.stopPropagation(); | |
| 462 | − props.setConfirmingArchiveThreadId((current) => | |
| 463 | − current === thread.id ? null : current, | |
| 464 | − ); | |
| 465 | − void props.attemptArchiveThread(thread.id); | |
| 466 | − }} | |
| 632 | + onPointerDown={stopPropagationOnPointerDown} | |
| 633 | + onClick={handleConfirmArchiveClick} | |
| 467 | 634 | > |
| 468 | 635 | Confirm |
| 469 | 636 | </button> |
| 470 | 637 | ) : !isThreadRunning ? ( |
| 471 | − props.appSettingsConfirmThreadArchive ? ( | |
| 638 | + appSettingsConfirmThreadArchive ? ( | |
| 472 | 639 | <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100"> |
| 473 | 640 | <button |
| 474 | 641 | type="button" |
| @@ -476,17 +643,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 1 unmodified line | ||
| 476 | 643 | data-testid={`thread-archive-${thread.id}`} |
| 477 | 644 | aria-label={`Archive ${thread.title}`} |
| 478 | 645 | className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" |
| 479 | − onPointerDown={(event) => { | |
| 480 | − event.stopPropagation(); | |
| 481 | − }} | |
| 482 | − onClick={(event) => { | |
| 483 | − event.preventDefault(); | |
| 484 | − event.stopPropagation(); | |
| 485 | − props.setConfirmingArchiveThreadId(thread.id); | |
| 486 | − requestAnimationFrame(() => { | |
| 487 | − props.confirmArchiveButtonRefs.current.get(thread.id)?.focus(); | |
| 488 | − }); | |
| 489 | − }} | |
| 646 | + onPointerDown={stopPropagationOnPointerDown} | |
| 647 | + onClick={handleStartArchiveConfirmation} | |
| 490 | 648 | > |
| 491 | 649 | <ArchiveIcon className="size-3.5" /> |
| 492 | 650 | </button> |
| @@ -502,14 +660,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 9 unmodified lines | ||
| 502 | 660 | data-testid={`thread-archive-${thread.id}`} |
| 503 | 661 | aria-label={`Archive ${thread.title}`} |
| 504 | 662 | className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" |
| 505 | − onPointerDown={(event) => { | |
| 506 | − event.stopPropagation(); | |
| 507 | − }} | |
| 508 | − onClick={(event) => { | |
| 509 | − event.preventDefault(); | |
| 510 | − event.stopPropagation(); | |
| 511 | − void props.attemptArchiveThread(thread.id); | |
| 512 | − }} | |
| 663 | + onPointerDown={stopPropagationOnPointerDown} | |
| 664 | + onClick={handleArchiveImmediateClick} | |
| 513 | 665 | > |
| 514 | 666 | <ArchiveIcon className="size-3.5" /> |
| 515 | 667 | </button> |
| @@ -521,12 +673,12 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 5 unmodified lines | ||
| 521 | 673 | ) |
| 522 | 674 | ) : null} |
| 523 | 675 | <span className={threadMetaClassName}> |
| 524 | − {props.showThreadJumpHints && props.jumpLabel ? ( | |
| 676 | + {jumpLabel ? ( | |
| 525 | 677 | <span |
| 526 | 678 | className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm" |
| 527 | − title={props.jumpLabel} | |
| 679 | + title={jumpLabel} | |
| 528 | 680 | > |
| 529 | − {props.jumpLabel} | |
| 681 | + {jumpLabel} | |
| 530 | 682 | </span> |
| 531 | 683 | ) : ( |
| 532 | 684 | <span |
| @@ -545,248 +697,288 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 12 unmodified lines | ||
| 545 | 697 | </SidebarMenuSubButton> |
| 546 | 698 | </SidebarMenuSubItem> |
| 547 | 699 | ); |
| 700 | +}); | |
| 701 | + | |
| 702 | +interface SidebarProjectThreadListProps { | |
| 703 | + projectKey: string; | |
| 704 | + projectExpanded: boolean; | |
| 705 | + hasOverflowingThreads: boolean; | |
| 706 | + hiddenThreadStatus: ThreadStatusPill | null; | |
| 707 | + orderedProjectThreadKeys: readonly string[]; | |
| 708 | + renderedThreads: readonly SidebarThreadSummary[]; | |
| 709 | + showEmptyThreadState: boolean; | |
| 710 | + shouldShowThreadPanel: boolean; | |
| 711 | + isThreadListExpanded: boolean; | |
| 712 | + projectCwd: string; | |
| 713 | + activeRouteThreadKey: string | null; | |
| 714 | + threadJumpLabelByKey: ReadonlyMap<string, string>; | |
| 715 | + appSettingsConfirmThreadArchive: boolean; | |
| 716 | + renamingThreadKey: string | null; | |
| 717 | + renamingTitle: string; | |
| 718 | + setRenamingTitle: (title: string) => void; | |
| 719 | + renamingInputRef: React.RefObject<HTMLInputElement | null>; | |
| 720 | + renamingCommittedRef: React.RefObject<boolean>; | |
| 721 | + confirmingArchiveThreadKey: string | null; | |
| 722 | + setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>; | |
| 723 | + confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>; | |
| 724 | + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; | |
| 725 | + handleThreadClick: ( | |
| 726 | + event: React.MouseEvent, | |
| 727 | + threadRef: ScopedThreadRef, | |
| 728 | + orderedProjectThreadKeys: readonly string[], | |
| 729 | + ) => void; | |
| 730 | + navigateToThread: (threadRef: ScopedThreadRef) => void; | |
| 731 | + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; | |
| 732 | + handleThreadContextMenu: ( | |
| 733 | + threadRef: ScopedThreadRef, | |
| 734 | + position: { x: number; y: number }, | |
| 735 | + ) => Promise<void>; | |
| 736 | + clearSelection: () => void; | |
| 737 | + commitRename: ( | |
| 738 | + threadRef: ScopedThreadRef, | |
| 739 | + newTitle: string, | |
| 740 | + originalTitle: string, | |
| 741 | + ) => Promise<void>; | |
| 742 | + cancelRename: () => void; | |
| 743 | + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>; | |
| 744 | + openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void; | |
| 745 | + expandThreadListForProject: (projectKey: string) => void; | |
| 746 | + collapseThreadListForProject: (projectKey: string) => void; | |
| 548 | 747 | } |
| 549 | 748 | |
| 550 | −function T3Wordmark() { | |
| 551 | − return ( | |
| 552 | − <svg | |
| 553 | − aria-label="T3" | |
| 554 | − className="h-2.5 w-auto shrink-0 text-foreground" | |
| 555 | − viewBox="15.5309 37 94.3941 56.96" | |
| 556 | − xmlns="http://www.w3.org/2000/svg" | |
| 557 | − > | |
| 558 | − <path | |
| 559 | − d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" | |
| 560 | − fill="currentColor" | |
| 561 | − /> | |
| 562 | − </svg> | |
| 563 | − ); | |
| 564 | −} | |
| 565 | − | |
| 566 | −type SortableProjectHandleProps = Pick< | |
| 567 | − ReturnType<typeof useSortable>, | |
| 568 | − "attributes" | "listeners" | "setActivatorNodeRef" | |
| 569 | −>; | |
| 749 | +const SidebarProjectThreadList = memo(function SidebarProjectThreadList( | |
| 750 | + props: SidebarProjectThreadListProps, | |
| 751 | +) { | |
| 752 | + const { | |
| 753 | + projectKey, | |
| 754 | + projectExpanded, | |
| 755 | + hasOverflowingThreads, | |
| 756 | + hiddenThreadStatus, | |
| 757 | + orderedProjectThreadKeys, | |
| 758 | + renderedThreads, | |
| 759 | + showEmptyThreadState, | |
| 760 | + shouldShowThreadPanel, | |
| 761 | + isThreadListExpanded, | |
| 762 | + projectCwd, | |
| 763 | + activeRouteThreadKey, | |
| 764 | + threadJumpLabelByKey, | |
| 765 | + appSettingsConfirmThreadArchive, | |
| 766 | + renamingThreadKey, | |
| 767 | + renamingTitle, | |
| 768 | + setRenamingTitle, | |
| 769 | + renamingInputRef, | |
| 770 | + renamingCommittedRef, | |
| 771 | + confirmingArchiveThreadKey, | |
| 772 | + setConfirmingArchiveThreadKey, | |
| 773 | + confirmArchiveButtonRefs, | |
| 774 | + attachThreadListAutoAnimateRef, | |
| 775 | + handleThreadClick, | |
| 776 | + navigateToThread, | |
| 777 | + handleMultiSelectContextMenu, | |
| 778 | + handleThreadContextMenu, | |
| 779 | + clearSelection, | |
| 780 | + commitRename, | |
| 781 | + cancelRename, | |
| 782 | + attemptArchiveThread, | |
| 783 | + openPrLink, | |
| 784 | + expandThreadListForProject, | |
| 785 | + collapseThreadListForProject, | |
| 786 | + } = props; | |
| 787 | + const showMoreButtonRender = useMemo(() => <button type="button" />, []); | |
| 788 | + const showLessButtonRender = useMemo(() => <button type="button" />, []); | |
| 570 | 789 | |
| 571 | −function ProjectSortMenu({ | |
| 572 | − projectSortOrder, | |
| 573 | − threadSortOrder, | |
| 574 | − onProjectSortOrderChange, | |
| 575 | − onThreadSortOrderChange, | |
| 576 | −}: { | |
| 577 | − projectSortOrder: SidebarProjectSortOrder; | |
| 578 | − threadSortOrder: SidebarThreadSortOrder; | |
| 579 | − onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; | |
| 580 | − onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; | |
| 581 | −}) { | |
| 582 | 790 | return ( |
| 583 | − <Menu> | |
| 584 | − <Tooltip> | |
| 585 | − <TooltipTrigger | |
| 586 | − render={ | |
| 587 | − <MenuTrigger className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" /> | |
| 588 | − } | |
| 589 | − > | |
| 590 | − <ArrowUpDownIcon className="size-3.5" /> | |
| 591 | − </TooltipTrigger> | |
| 592 | − <TooltipPopup side="right">Sort projects</TooltipPopup> | |
| 593 | − </Tooltip> | |
| 594 | − <MenuPopup align="end" side="bottom" className="min-w-44"> | |
| 595 | − <MenuGroup> | |
| 596 | − <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground"> | |
| 597 | − Sort projects | |
| 791 | + <SidebarMenuSub | |
| 792 | + ref={attachThreadListAutoAnimateRef} | |
| 793 | + className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0" | |
| 794 | + > | |
| 795 | + {shouldShowThreadPanel && showEmptyThreadState ? ( | |
| 796 | + <SidebarMenuSubItem className="w-full" data-thread-selection-safe> | |
| 797 | + <div | |
| 798 | + data-thread-selection-safe | |
| 799 | + className="flex h-6 w-full translate-x-0 items-center px-2 text-left text-[10px] text-muted-foreground/60" | |
| 800 | + > | |
| 801 | + <span>No threads yet</span> | |
| 598 | 802 | </div> |
| 599 | − <MenuRadioGroup | |
| 600 | − value={projectSortOrder} | |
| 601 | − onValueChange={(value) => { | |
| 602 | − onProjectSortOrderChange(value as SidebarProjectSortOrder); | |
| 803 | + </SidebarMenuSubItem> | |
| 804 | + ) : null} | |
| 805 | + {shouldShowThreadPanel && | |
| 806 | + renderedThreads.map((thread) => { | |
| 807 | + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); | |
| 808 | + return ( | |
| 809 | + <SidebarThreadRow | |
| 810 | + key={threadKey} | |
| 811 | + thread={thread} | |
| 812 | + projectCwd={projectCwd} | |
| 813 | + orderedProjectThreadKeys={orderedProjectThreadKeys} | |
| 814 | + isActive={activeRouteThreadKey === threadKey} | |
| 815 | + jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null} | |
| 816 | + appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} | |
| 817 | + renamingThreadKey={renamingThreadKey} | |
| 818 | + renamingTitle={renamingTitle} | |
| 819 | + setRenamingTitle={setRenamingTitle} | |
| 820 | + renamingInputRef={renamingInputRef} | |
| 821 | + renamingCommittedRef={renamingCommittedRef} | |
| 822 | + confirmingArchiveThreadKey={confirmingArchiveThreadKey} | |
| 823 | + setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey} | |
| 824 | + confirmArchiveButtonRefs={confirmArchiveButtonRefs} | |
| 825 | + handleThreadClick={handleThreadClick} | |
| 826 | + navigateToThread={navigateToThread} | |
| 827 | + handleMultiSelectContextMenu={handleMultiSelectContextMenu} | |
| 828 | + handleThreadContextMenu={handleThreadContextMenu} | |
| 829 | + clearSelection={clearSelection} | |
| 830 | + commitRename={commitRename} | |
| 831 | + cancelRename={cancelRename} | |
| 832 | + attemptArchiveThread={attemptArchiveThread} | |
| 833 | + openPrLink={openPrLink} | |
| 834 | + /> | |
| 835 | + ); | |
| 836 | + })} | |
| 837 | + | |
| 838 | + {projectExpanded && hasOverflowingThreads && !isThreadListExpanded && ( | |
| 839 | + <SidebarMenuSubItem className="w-full"> | |
| 840 | + <SidebarMenuSubButton | |
| 841 | + render={showMoreButtonRender} | |
| 842 | + data-thread-selection-safe | |
| 843 | + size="sm" | |
| 844 | + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" | |
| 845 | + onClick={() => { | |
| 846 | + expandThreadListForProject(projectKey); | |
| 603 | 847 | }} |
| 604 | 848 | > |
| 605 | − {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( | |
| 606 | − ([value, label]) => ( | |
| 607 | − <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs"> | |
| 608 | − {label} | |
| 609 | − </MenuRadioItem> | |
| 610 | − ), | |
| 611 | − )} | |
| 612 | − </MenuRadioGroup> | |
| 613 | − </MenuGroup> | |
| 614 | − <MenuGroup> | |
| 615 | − <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground"> | |
| 616 | − Sort threads | |
| 617 | − </div> | |
| 618 | − <MenuRadioGroup | |
| 619 | − value={threadSortOrder} | |
| 620 | − onValueChange={(value) => { | |
| 621 | − onThreadSortOrderChange(value as SidebarThreadSortOrder); | |
| 849 | + <span className="flex min-w-0 flex-1 items-center gap-2"> | |
| 850 | + {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />} | |
| 851 | + <span>Show more</span> | |
| 852 | + </span> | |
| 853 | + </SidebarMenuSubButton> | |
| 854 | + </SidebarMenuSubItem> | |
| 855 | + )} | |
| 856 | + {projectExpanded && hasOverflowingThreads && isThreadListExpanded && ( | |
| 857 | + <SidebarMenuSubItem className="w-full"> | |
| 858 | + <SidebarMenuSubButton | |
| 859 | + render={showLessButtonRender} | |
| 860 | + data-thread-selection-safe | |
| 861 | + size="sm" | |
| 862 | + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" | |
| 863 | + onClick={() => { | |
| 864 | + collapseThreadListForProject(projectKey); | |
| 622 | 865 | }} |
| 623 | 866 | > |
| 624 | − {( | |
| 625 | − Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> | |
| 626 | − ).map(([value, label]) => ( | |
| 627 | − <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs"> | |
| 628 | − {label} | |
| 629 | − </MenuRadioItem> | |
| 630 | − ))} | |
| 631 | − </MenuRadioGroup> | |
| 632 | − </MenuGroup> | |
| 633 | − </MenuPopup> | |
| 634 | − </Menu> | |
| 867 | + <span>Show less</span> | |
| 868 | + </SidebarMenuSubButton> | |
| 869 | + </SidebarMenuSubItem> | |
| 870 | + )} | |
| 871 | + </SidebarMenuSub> | |
| 635 | 872 | ); |
| 873 | +}); | |
| 874 | + | |
| 875 | +interface SidebarProjectItemProps { | |
| 876 | + project: SidebarProjectSnapshot; | |
| 877 | + isThreadListExpanded: boolean; | |
| 878 | + activeRouteThreadKey: string | null; | |
| 879 | + newThreadShortcutLabel: string | null; | |
| 880 | + handleNewThread: ReturnType<typeof useHandleNewThread>["handleNewThread"]; | |
| 881 | + archiveThread: ReturnType<typeof useThreadActions>["archiveThread"]; | |
| 882 | + deleteThread: ReturnType<typeof useThreadActions>["deleteThread"]; | |
| 883 | + threadJumpLabelByKey: ReadonlyMap<string, string>; | |
| 884 | + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; | |
| 885 | + expandThreadListForProject: (projectKey: string) => void; | |
| 886 | + collapseThreadListForProject: (projectKey: string) => void; | |
| 887 | + dragInProgressRef: React.RefObject<boolean>; | |
| 888 | + suppressProjectClickAfterDragRef: React.RefObject<boolean>; | |
| 889 | + suppressProjectClickForContextMenuRef: React.RefObject<boolean>; | |
| 890 | + isManualProjectSorting: boolean; | |
| 891 | + dragHandleProps: SortableProjectHandleProps | null; | |
| 636 | 892 | } |
| 637 | 893 | |
| 638 | −function SortableProjectItem({ | |
| 639 | − projectId, | |
| 640 | − disabled = false, | |
| 641 | − children, | |
| 642 | −}: { | |
| 643 | − projectId: ProjectId; | |
| 644 | − disabled?: boolean; | |
| 645 | − children: (handleProps: SortableProjectHandleProps) => ReactNode; | |
| 646 | −}) { | |
| 894 | +const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { | |
| 647 | 895 | const { |
| 648 | − attributes, | |
| 649 | − listeners, | |
| 650 | − setActivatorNodeRef, | |
| 651 | − setNodeRef, | |
| 652 | − transform, | |
| 653 | − transition, | |
| 654 | − isDragging, | |
| 655 | − isOver, | |
| 656 | − } = useSortable({ id: projectId, disabled }); | |
| 657 | − return ( | |
| 658 | − <li | |
| 659 | − ref={setNodeRef} | |
| 660 | − style={{ | |
| 661 | − transform: CSS.Translate.toString(transform), | |
| 662 | − transition, | |
| 663 | − }} | |
| 664 | − className={`group/menu-item relative rounded-md ${ | |
| 665 | − isDragging ? "z-20 opacity-80" : "" | |
| 666 | − } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`} | |
| 667 | − data-sidebar="menu-item" | |
| 668 | − data-slot="sidebar-menu-item" | |
| 669 | − > | |
| 670 | − {children({ attributes, listeners, setActivatorNodeRef })} | |
| 671 | − </li> | |
| 896 | + project, | |
| 897 | + isThreadListExpanded, | |
| 898 | + activeRouteThreadKey, | |
| 899 | + newThreadShortcutLabel, | |
| 900 | + handleNewThread, | |
| 901 | + archiveThread, | |
| 902 | + deleteThread, | |
| 903 | + threadJumpLabelByKey, | |
| 904 | + attachThreadListAutoAnimateRef, | |
| 905 | + expandThreadListForProject, | |
| 906 | + collapseThreadListForProject, | |
| 907 | + dragInProgressRef, | |
| 908 | + suppressProjectClickAfterDragRef, | |
| 909 | + suppressProjectClickForContextMenuRef, | |
| 910 | + isManualProjectSorting, | |
| 911 | + dragHandleProps, | |
| 912 | + } = props; | |
| 913 | + const threadSortOrder = useSettings<SidebarThreadSortOrder>( | |
| 914 | + (settings) => settings.sidebarThreadSortOrder, | |
| 672 | 915 | ); |
| 673 | −} | |
| 674 | − | |
| 675 | −export default function Sidebar() { | |
| 676 | − const projectIds = useStore((store) => store.projectIds); | |
| 677 | − const projectById = useStore((store) => store.projectById); | |
| 678 | − const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById); | |
| 679 | − const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); | |
| 680 | − const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( | |
| 681 | − useShallow((store) => ({ | |
| 682 | − projectExpandedById: store.projectExpandedById, | |
| 683 | − projectOrder: store.projectOrder, | |
| 684 | − threadLastVisitedAtById: store.threadLastVisitedAtById, | |
| 685 | − })), | |
| 686 | − ); | |
| 687 | − const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); | |
| 688 | − const toggleProject = useUiStateStore((store) => store.toggleProject); | |
| 689 | − const reorderProjects = useUiStateStore((store) => store.reorderProjects); | |
| 690 | − const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); | |
| 691 | − const getDraftThreadByProjectId = useComposerDraftStore( | |
| 692 | − (store) => store.getDraftThreadByProjectId, | |
| 916 | + const appSettingsConfirmThreadDelete = useSettings<boolean>( | |
| 917 | + (settings) => settings.confirmThreadDelete, | |
| 918 | + ); | |
| 919 | + const appSettingsConfirmThreadArchive = useSettings<boolean>( | |
| 920 | + (settings) => settings.confirmThreadArchive, | |
| 921 | + ); | |
| 922 | + const defaultThreadEnvMode = useSettings<ThreadEnvMode>( | |
| 923 | + (settings) => settings.defaultThreadEnvMode, | |
| 924 | + ); | |
| 925 | + const router = useRouter(); | |
| 926 | + const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); | |
| 927 | + const toggleProject = useUiStateStore((state) => state.toggleProject); | |
| 928 | + const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); | |
| 929 | + const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); | |
| 930 | + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); | |
| 931 | + const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); | |
| 932 | + const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); | |
| 933 | + const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); | |
| 934 | + const clearComposerDraftForThread = useComposerDraftStore((state) => state.clearDraftThread); | |
| 935 | + const getDraftThreadByProjectRef = useComposerDraftStore( | |
| 936 | + (state) => state.getDraftThreadByProjectRef, | |
| 693 | 937 | ); |
| 694 | − const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); | |
| 695 | 938 | const clearProjectDraftThreadId = useComposerDraftStore( |
| 696 | − (store) => store.clearProjectDraftThreadId, | |
| 939 | + (state) => state.clearProjectDraftThreadId, | |
| 697 | 940 | ); |
| 698 | − const navigate = useNavigate(); | |
| 699 | − const pathname = useLocation({ select: (loc) => loc.pathname }); | |
| 700 | − const isOnSettings = pathname.startsWith("/settings"); | |
| 701 | − const appSettings = useSettings(); | |
| 702 | − const { updateSettings } = useUpdateSettings(); | |
| 703 | − const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); | |
| 704 | − const { archiveThread, deleteThread } = useThreadActions(); | |
| 705 | − const routeThreadId = useParams({ | |
| 706 | − strict: false, | |
| 707 | − select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | |
| 941 | + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ | |
| 942 | + threadId: ThreadId; | |
| 943 | + }>({ | |
| 944 | + onCopy: (ctx) => { | |
| 945 | + toastManager.add({ | |
| 946 | + type: "success", | |
| 947 | + title: "Thread ID copied", | |
| 948 | + description: ctx.threadId, | |
| 949 | + }); | |
| 950 | + }, | |
| 951 | + onError: (error) => { | |
| 952 | + toastManager.add({ | |
| 953 | + type: "error", | |
| 954 | + title: "Failed to copy thread ID", | |
| 955 | + description: error instanceof Error ? error.message : "An error occurred.", | |
| 956 | + }); | |
| 957 | + }, | |
| 708 | 958 | }); |
| 709 | − const keybindings = useServerKeybindings(); | |
| 710 | − const [addingProject, setAddingProject] = useState(false); | |
| 711 | − const [newCwd, setNewCwd] = useState(""); | |
| 712 | − const [isPickingFolder, setIsPickingFolder] = useState(false); | |
| 713 | − const [isAddingProject, setIsAddingProject] = useState(false); | |
| 714 | − const [addProjectError, setAddProjectError] = useState<string | null>(null); | |
| 715 | − const addProjectInputRef = useRef<HTMLInputElement | null>(null); | |
| 716 | − const [renamingProjectId, setRenamingProjectId] = useState<ProjectId | null>(null); | |
| 717 | − const [renamingProjectTitle, setRenamingProjectTitle] = useState(""); | |
| 718 | − const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); | |
| 719 | − const [renamingTitle, setRenamingTitle] = useState(""); | |
| 720 | − const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null); | |
| 721 | − const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< | |
| 722 | − ReadonlySet<ProjectId> | |
| 723 | − >(() => new Set()); | |
| 724 | − const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); | |
| 725 | − const projectRenamingCommittedRef = useRef(false); | |
| 726 | − const projectRenamingInputRef = useRef<HTMLInputElement | null>(null); | |
| 727 | − const renamingCommittedRef = useRef(false); | |
| 728 | − const renamingInputRef = useRef<HTMLInputElement | null>(null); | |
| 729 | − const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>()); | |
| 730 | − const dragInProgressRef = useRef(false); | |
| 731 | − const suppressProjectClickAfterDragRef = useRef(false); | |
| 732 | − const suppressProjectClickForContextMenuRef = useRef(false); | |
| 733 | − const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null); | |
| 734 | − const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); | |
| 735 | − const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); | |
| 736 | − const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); | |
| 737 | − const clearSelection = useThreadSelectionStore((s) => s.clearSelection); | |
| 738 | − const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); | |
| 739 | − const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); | |
| 740 | − const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); | |
| 741 | − const platform = navigator.platform; | |
| 742 | − const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; | |
| 743 | − const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; | |
| 744 | − const projects = useMemo( | |
| 745 | − () => | |
| 746 | − projectIds.flatMap((projectId) => { | |
| 747 | − const project = projectById[projectId]; | |
| 748 | − return project ? [project] : []; | |
| 749 | − }), | |
| 750 | − [projectById, projectIds], | |
| 751 | − ); | |
| 752 | − const orderedProjects = useMemo(() => { | |
| 753 | − return orderItemsByPreferredIds({ | |
| 754 | − items: projects, | |
| 755 | − preferredIds: projectOrder, | |
| 756 | − getId: (project) => project.id, | |
| 757 | − }); | |
| 758 | − }, [projectOrder, projects]); | |
| 759 | − const sidebarProjects = useMemo<SidebarProjectSnapshot[]>( | |
| 760 | − () => | |
| 761 | − orderedProjects.map((project) => ({ | |
| 762 | − ...project, | |
| 763 | − expanded: projectExpandedById[project.id] ?? true, | |
| 764 | − })), | |
| 765 | − [orderedProjects, projectExpandedById], | |
| 766 | − ); | |
| 767 | − const sidebarThreads = useMemo(() => Object.values(sidebarThreadsById), [sidebarThreadsById]); | |
| 768 | − const projectCwdById = useMemo( | |
| 769 | − () => new Map(projects.map((project) => [project.id, project.cwd] as const)), | |
| 770 | − [projects], | |
| 771 | − ); | |
| 772 | − const routeTerminalOpen = routeThreadId | |
| 773 | − ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen | |
| 774 | − : false; | |
| 775 | − const sidebarShortcutLabelOptions = useMemo( | |
| 776 | − () => ({ | |
| 777 | − platform, | |
| 778 | − context: { | |
| 779 | − terminalFocus: false, | |
| 780 | − terminalOpen: routeTerminalOpen, | |
| 781 | − }, | |
| 782 | − }), | |
| 783 | − [platform, routeTerminalOpen], | |
| 784 | − ); | |
| 785 | − const openPrLink = useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => { | |
| 959 | + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ | |
| 960 | + path: string; | |
| 961 | + }>({ | |
| 962 | + onCopy: (ctx) => { | |
| 963 | + toastManager.add({ | |
| 964 | + type: "success", | |
| 965 | + title: "Path copied", | |
| 966 | + description: ctx.path, | |
| 967 | + }); | |
| 968 | + }, | |
| 969 | + onError: (error) => { | |
| 970 | + toastManager.add({ | |
| 971 | + type: "error", | |
| 972 | + title: "Failed to copy path", | |
| 973 | + description: error instanceof Error ? error.message : "An error occurred.", | |
| 974 | + }); | |
| 975 | + }, | |
| 976 | + }); | |
| 977 | + const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => { | |
| 786 | 978 | event.preventDefault(); |
| 787 | 979 | event.stopPropagation(); |
| 788 | 980 | |
| 789 | − const api = readNativeApi(); | |
| 981 | + const api = readLocalApi(); | |
| 790 | 982 | if (!api) { |
| 791 | 983 | toastManager.add({ |
| 792 | 984 | type: "error", |
| @@ -803,222 +995,495 @@ export default function Sidebar() { · 10 unmodified lines | ||
| 803 | 995 | }); |
| 804 | 996 | }); |
| 805 | 997 | }, []); |
| 998 | + const sidebarThreads = useStore( | |
| 999 | + useShallow( | |
| 1000 | + useMemo( | |
| 1001 | + () => (state) => | |
| 1002 | + selectSidebarThreadsForProjectRef( | |
| 1003 | + state, | |
| 1004 | + scopeProjectRef(project.environmentId, project.id), | |
| 1005 | + ), | |
| 1006 | + [project.environmentId, project.id], | |
| 1007 | + ), | |
| 1008 | + ), | |
| 1009 | + ); | |
| 1010 | + const sidebarThreadByKey = useMemo( | |
| 1011 | + () => | |
| 1012 | + new Map( | |
| 1013 | + sidebarThreads.map( | |
| 1014 | + (thread) => | |
| 1015 | + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, | |
| 1016 | + ), | |
| 1017 | + ), | |
| 1018 | + [sidebarThreads], | |
| 1019 | + ); | |
| 1020 | + const projectThreads = useMemo( | |
| 1021 | + () => | |
| 1022 | + sidebarThreads.filter( | |
| 1023 | + (thread) => | |
| 1024 | + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)) === | |
| 1025 | + project.projectKey, | |
| 1026 | + ), | |
| 1027 | + [project.projectKey, sidebarThreads], | |
| 1028 | + ); | |
| 1029 | + const projectExpanded = useUiStateStore( | |
| 1030 | + (state) => state.projectExpandedById[project.projectKey] ?? true, | |
| 1031 | + ); | |
| 1032 | + const threadLastVisitedAts = useUiStateStore( | |
| 1033 | + useShallow((state) => | |
| 1034 | + projectThreads.map( | |
| 1035 | + (thread) => | |
| 1036 | + state.threadLastVisitedAtById[ | |
| 1037 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) | |
| 1038 | + ] ?? null, | |
| 1039 | + ), | |
| 1040 | + ), | |
| 1041 | + ); | |
| 1042 | + const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null); | |
| 1043 | + const [renamingTitle, setRenamingTitle] = useState(""); | |
| 1044 | + const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null); | |
| 1045 | + const renamingCommittedRef = useRef(false); | |
| 1046 | + const renamingInputRef = useRef<HTMLInputElement | null>(null); | |
| 1047 | + const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>()); | |
| 1048 | + | |
| 1049 | + const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { | |
| 1050 | + const lastVisitedAtByThreadKey = new Map( | |
| 1051 | + projectThreads.map((thread, index) => [ | |
| 1052 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 1053 | + threadLastVisitedAts[index] ?? null, | |
| 1054 | + ]), | |
| 1055 | + ); | |
| 1056 | + const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { | |
| 1057 | + const lastVisitedAt = lastVisitedAtByThreadKey.get( | |
| 1058 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 1059 | + ); | |
| 1060 | + return resolveThreadStatusPill({ | |
| 1061 | + thread: { | |
| 1062 | + ...thread, | |
| 1063 | + ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), | |
| 1064 | + }, | |
| 1065 | + }); | |
| 1066 | + }; | |
| 1067 | + const visibleProjectThreads = sortThreadsForSidebar( | |
| 1068 | + projectThreads.filter((thread) => thread.archivedAt === null), | |
| 1069 | + threadSortOrder, | |
| 1070 | + ); | |
| 1071 | + const projectStatus = resolveProjectStatusIndicator( | |
| 1072 | + visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)), | |
| 1073 | + ); | |
| 1074 | + return { | |
| 1075 | + orderedProjectThreadKeys: visibleProjectThreads.map((thread) => | |
| 1076 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 1077 | + ), | |
| 1078 | + projectStatus, | |
| 1079 | + visibleProjectThreads, | |
| 1080 | + }; | |
| 1081 | + }, [projectThreads, threadLastVisitedAts, threadSortOrder]); | |
| 806 | 1082 | |
| 807 | − const attemptArchiveThread = useCallback( | |
| 808 | − async (threadId: ThreadId) => { | |
| 809 | − try { | |
| 810 | − await archiveThread(threadId); | |
| 811 | − } catch (error) { | |
| 812 | − toastManager.add({ | |
| 813 | − type: "error", | |
| 814 | − title: "Failed to archive thread", | |
| 815 | − description: error instanceof Error ? error.message : "An error occurred.", | |
| 816 | − }); | |
| 1083 | + const pinnedCollapsedThread = useMemo(() => { | |
| 1084 | + const activeThreadKey = activeRouteThreadKey ?? undefined; | |
| 1085 | + if (!activeThreadKey || projectExpanded) { | |
| 1086 | + return null; | |
| 1087 | + } | |
| 1088 | + return ( | |
| 1089 | + visibleProjectThreads.find( | |
| 1090 | + (thread) => | |
| 1091 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, | |
| 1092 | + ) ?? null | |
| 1093 | + ); | |
| 1094 | + }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]); | |
| 1095 | + | |
| 1096 | + const { | |
| 1097 | + hasOverflowingThreads, | |
| 1098 | + hiddenThreadStatus, | |
| 1099 | + renderedThreads, | |
| 1100 | + showEmptyThreadState, | |
| 1101 | + shouldShowThreadPanel, | |
| 1102 | + } = useMemo(() => { | |
| 1103 | + const lastVisitedAtByThreadKey = new Map( | |
| 1104 | + projectThreads.map((thread, index) => [ | |
| 1105 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 1106 | + threadLastVisitedAts[index] ?? null, | |
| 1107 | + ]), | |
| 1108 | + ); | |
| 1109 | + const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { | |
| 1110 | + const lastVisitedAt = lastVisitedAtByThreadKey.get( | |
| 1111 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 1112 | + ); | |
| 1113 | + return resolveThreadStatusPill({ | |
| 1114 | + thread: { | |
| 1115 | + ...thread, | |
| 1116 | + ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), | |
| 1117 | + }, | |
| 1118 | + }); | |
| 1119 | + }; | |
| 1120 | + const hasOverflowingThreads = visibleProjectThreads.length > THREAD_PREVIEW_LIMIT; | |
| 1121 | + const previewThreads = | |
| 1122 | + isThreadListExpanded || !hasOverflowingThreads | |
| 1123 | + ? visibleProjectThreads | |
| 1124 | + : visibleProjectThreads.slice(0, THREAD_PREVIEW_LIMIT); | |
| 1125 | + const visibleThreadKeys = new Set( | |
| 1126 | + [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) => | |
| 1127 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 1128 | + ), | |
| 1129 | + ); | |
| 1130 | + const renderedThreads = pinnedCollapsedThread | |
| 1131 | + ? [pinnedCollapsedThread] | |
| 1132 | + : visibleProjectThreads.filter((thread) => | |
| 1133 | + visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), | |
| 1134 | + ); | |
| 1135 | + const hiddenThreads = visibleProjectThreads.filter( | |
| 1136 | + (thread) => | |
| 1137 | + !visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), | |
| 1138 | + ); | |
| 1139 | + return { | |
| 1140 | + hasOverflowingThreads, | |
| 1141 | + hiddenThreadStatus: resolveProjectStatusIndicator( | |
| 1142 | + hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), | |
| 1143 | + ), | |
| 1144 | + renderedThreads, | |
| 1145 | + showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0, | |
| 1146 | + shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null, | |
| 1147 | + }; | |
| 1148 | + }, [ | |
| 1149 | + isThreadListExpanded, | |
| 1150 | + pinnedCollapsedThread, | |
| 1151 | + projectExpanded, | |
| 1152 | + projectThreads, | |
| 1153 | + threadLastVisitedAts, | |
| 1154 | + visibleProjectThreads, | |
| 1155 | + ]); | |
| 1156 | + | |
| 1157 | + const handleProjectButtonClick = useCallback( | |
| 1158 | + (event: React.MouseEvent<HTMLButtonElement>) => { | |
| 1159 | + if (suppressProjectClickForContextMenuRef.current) { | |
| 1160 | + suppressProjectClickForContextMenuRef.current = false; | |
| 1161 | + event.preventDefault(); | |
| 1162 | + event.stopPropagation(); | |
| 1163 | + return; | |
| 1164 | + } | |
| 1165 | + if (dragInProgressRef.current) { | |
| 1166 | + event.preventDefault(); | |
| 1167 | + event.stopPropagation(); | |
| 1168 | + return; | |
| 1169 | + } | |
| 1170 | + if (suppressProjectClickAfterDragRef.current) { | |
| 1171 | + suppressProjectClickAfterDragRef.current = false; | |
| 1172 | + event.preventDefault(); | |
| 1173 | + event.stopPropagation(); | |
| 1174 | + return; | |
| 1175 | + } | |
| 1176 | + if (selectedThreadCount > 0) { | |
| 1177 | + clearSelection(); | |
| 817 | 1178 | } |
| 1179 | + toggleProject(project.projectKey); | |
| 818 | 1180 | }, |
| 819 | − [archiveThread], | |
| 1181 | + [ | |
| 1182 | + clearSelection, | |
| 1183 | + dragInProgressRef, | |
| 1184 | + project.projectKey, | |
| 1185 | + selectedThreadCount, | |
| 1186 | + suppressProjectClickAfterDragRef, | |
| 1187 | + suppressProjectClickForContextMenuRef, | |
| 1188 | + toggleProject, | |
| 1189 | + ], | |
| 820 | 1190 | ); |
| 821 | 1191 | |
| 822 | − const focusMostRecentThreadForProject = useCallback( | |
| 823 | − (projectId: ProjectId) => { | |
| 824 | − const latestThread = sortThreadsForSidebar( | |
| 825 | − (threadIdsByProjectId[projectId] ?? []) | |
| 826 | − .map((threadId) => sidebarThreadsById[threadId]) | |
| 827 | − .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined) | |
| 828 | − .filter((thread) => thread.archivedAt === null), | |
| 829 | − appSettings.sidebarThreadSortOrder, | |
| 830 | − )[0]; | |
| 831 | − if (!latestThread) return; | |
| 832 | − | |
| 833 | − void navigate({ | |
| 834 | − to: "/$threadId", | |
| 835 | − params: { threadId: latestThread.id }, | |
| 836 | − }); | |
| 1192 | + const handleProjectButtonKeyDown = useCallback( | |
| 1193 | + (event: React.KeyboardEvent<HTMLButtonElement>) => { | |
| 1194 | + if (event.key !== "Enter" && event.key !== " ") return; | |
| 1195 | + event.preventDefault(); | |
| 1196 | + if (dragInProgressRef.current) { | |
| 1197 | + return; | |
| 1198 | + } | |
| 1199 | + toggleProject(project.projectKey); | |
| 837 | 1200 | }, |
| 838 | − [appSettings.sidebarThreadSortOrder, navigate, sidebarThreadsById, threadIdsByProjectId], | |
| 1201 | + [dragInProgressRef, project.projectKey, toggleProject], | |
| 839 | 1202 | ); |
| 840 | 1203 | |
| 841 | − const addProjectFromPath = useCallback( | |
| 842 | − async (rawCwd: string) => { | |
| 843 | − const cwd = rawCwd.trim(); | |
| 844 | − if (!cwd || isAddingProject) return; | |
| 845 | − const api = readNativeApi(); | |
| 846 | − if (!api) return; | |
| 1204 | + const handleProjectButtonPointerDownCapture = useCallback( | |
| 1205 | + (event: React.PointerEvent<HTMLButtonElement>) => { | |
| 1206 | + suppressProjectClickForContextMenuRef.current = false; | |
| 1207 | + if ( | |
| 1208 | + isContextMenuPointerDown({ | |
| 1209 | + button: event.button, | |
| 1210 | + ctrlKey: event.ctrlKey, | |
| 1211 | + isMac: isMacPlatform(navigator.platform), | |
| 1212 | + }) | |
| 1213 | + ) { | |
| 1214 | + event.stopPropagation(); | |
| 1215 | + } | |
| 847 | 1216 | |
| 848 | − setIsAddingProject(true); | |
| 849 | − const finishAddingProject = () => { | |
| 850 | − setIsAddingProject(false); | |
| 851 | − setNewCwd(""); | |
| 852 | − setAddProjectError(null); | |
| 853 | − setAddingProject(false); | |
| 854 | − }; | |
| 1217 | + suppressProjectClickAfterDragRef.current = false; | |
| 1218 | + }, | |
| 1219 | + [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef], | |
| 1220 | + ); | |
| 855 | 1221 | |
| 856 | − const existing = projects.find((project) => project.cwd === cwd); | |
| 857 | − if (existing) { | |
| 858 | − focusMostRecentThreadForProject(existing.id); | |
| 859 | − finishAddingProject(); | |
| 860 | − return; | |
| 861 | − } | |
| 1222 | + const handleProjectButtonContextMenu = useCallback( | |
| 1223 | + (event: React.MouseEvent<HTMLButtonElement>) => { | |
| 1224 | + event.preventDefault(); | |
| 1225 | + suppressProjectClickForContextMenuRef.current = true; | |
| 1226 | + void (async () => { | |
| 1227 | + const api = readLocalApi(); | |
| 1228 | + if (!api) return; | |
| 862 | 1229 | |
| 863 | − const projectId = newProjectId(); | |
| 864 | − const createdAt = new Date().toISOString(); | |
| 865 | − const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; | |
| 866 | − try { | |
| 867 | − await api.orchestration.dispatchCommand({ | |
| 868 | − type: "project.create", | |
| 869 | − commandId: newCommandId(), | |
| 870 | − projectId, | |
| 871 | − title, | |
| 872 | − workspaceRoot: cwd, | |
| 873 | − defaultModelSelection: { | |
| 874 | − provider: "codex", | |
| 875 | − model: DEFAULT_MODEL_BY_PROVIDER.codex, | |
| 1230 | + const clicked = await api.contextMenu.show( | |
| 1231 | + [ | |
| 1232 | + { id: "copy-path", label: "Copy Project Path" }, | |
| 1233 | + { id: "delete", label: "Remove project", destructive: true }, | |
| 1234 | + ], | |
| 1235 | + { | |
| 1236 | + x: event.clientX, | |
| 1237 | + y: event.clientY, | |
| 876 | 1238 | }, |
| 877 | − createdAt, | |
| 878 | − }); | |
| 879 | − await handleNewThread(projectId, { | |
| 880 | − envMode: appSettings.defaultThreadEnvMode, | |
| 881 | − }).catch(() => undefined); | |
| 882 | − } catch (error) { | |
| 883 | − const description = | |
| 884 | − error instanceof Error ? error.message : "An error occurred while adding the project."; | |
| 885 | − setIsAddingProject(false); | |
| 886 | − if (shouldBrowseForProjectImmediately) { | |
| 1239 | + ); | |
| 1240 | + if (clicked === "copy-path") { | |
| 1241 | + copyPathToClipboard(project.cwd, { path: project.cwd }); | |
| 1242 | + return; | |
| 1243 | + } | |
| 1244 | + if (clicked !== "delete") return; | |
| 1245 | + | |
| 1246 | + if (projectThreads.length > 0) { | |
| 1247 | + toastManager.add({ | |
| 1248 | + type: "warning", | |
| 1249 | + title: "Project is not empty", | |
| 1250 | + description: "Delete all threads in this project before removing it.", | |
| 1251 | + }); | |
| 1252 | + return; | |
| 1253 | + } | |
| 1254 | + | |
| 1255 | + const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); | |
| 1256 | + if (!confirmed) return; | |
| 1257 | + | |
| 1258 | + try { | |
| 1259 | + const projectDraftThread = getDraftThreadByProjectRef( | |
| 1260 | + scopeProjectRef(project.environmentId, project.id), | |
| 1261 | + ); | |
| 1262 | + if (projectDraftThread) { | |
| 1263 | + clearComposerDraftForThread(projectDraftThread.draftId); | |
| 1264 | + } | |
| 1265 | + clearProjectDraftThreadId(scopeProjectRef(project.environmentId, project.id)); | |
| 1266 | + const projectApi = readEnvironmentApi(project.environmentId); | |
| 1267 | + if (!projectApi) { | |
| 1268 | + throw new Error("Project API unavailable."); | |
| 1269 | + } | |
| 1270 | + await projectApi.orchestration.dispatchCommand({ | |
| 1271 | + type: "project.delete", | |
| 1272 | + commandId: newCommandId(), | |
| 1273 | + projectId: project.id, | |
| 1274 | + }); | |
| 1275 | + } catch (error) { | |
| 1276 | + const message = | |
| 1277 | + error instanceof Error ? error.message : "Unknown error removing project."; | |
| 1278 | + console.error("Failed to remove project", { projectId: project.id, error }); | |
| 887 | 1279 | toastManager.add({ |
| 888 | 1280 | type: "error", |
| 889 | − title: "Failed to add project", | |
| 890 | − description, | |
| 1281 | + title: `Failed to remove "${project.name}"`, | |
| 1282 | + description: message, | |
| 891 | 1283 | }); |
| 892 | − } else { | |
| 893 | − setAddProjectError(description); | |
| 894 | 1284 | } |
| 895 | − return; | |
| 896 | − } | |
| 897 | − finishAddingProject(); | |
| 1285 | + })(); | |
| 898 | 1286 | }, |
| 899 | 1287 | [ |
| 900 | − focusMostRecentThreadForProject, | |
| 901 | − handleNewThread, | |
| 902 | − isAddingProject, | |
| 903 | − projects, | |
| 904 | − shouldBrowseForProjectImmediately, | |
| 905 | − appSettings.defaultThreadEnvMode, | |
| 1288 | + clearComposerDraftForThread, | |
| 1289 | + clearProjectDraftThreadId, | |
| 1290 | + copyPathToClipboard, | |
| 1291 | + getDraftThreadByProjectRef, | |
| 1292 | + project.cwd, | |
| 1293 | + project.environmentId, | |
| 1294 | + project.id, | |
| 1295 | + project.name, | |
| 1296 | + projectThreads.length, | |
| 1297 | + suppressProjectClickForContextMenuRef, | |
| 906 | 1298 | ], |
| 907 | 1299 | ); |
| 908 | 1300 | |
| 909 | − const handleAddProject = () => { | |
| 910 | − void addProjectFromPath(newCwd); | |
| 911 | − }; | |
| 1301 | + const navigateToThread = useCallback( | |
| 1302 | + (threadRef: ScopedThreadRef) => { | |
| 1303 | + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { | |
| 1304 | + clearSelection(); | |
| 1305 | + } | |
| 1306 | + setSelectionAnchor(scopedThreadKey(threadRef)); | |
| 1307 | + void router.navigate({ | |
| 1308 | + to: "/$environmentId/$threadId", | |
| 1309 | + params: buildThreadRouteParams(threadRef), | |
| 1310 | + }); | |
| 1311 | + }, | |
| 1312 | + [clearSelection, router, setSelectionAnchor], | |
| 1313 | + ); | |
| 912 | 1314 | |
| 913 | − const canAddProject = newCwd.trim().length > 0 && !isAddingProject; | |
| 1315 | + const handleThreadClick = useCallback( | |
| 1316 | + ( | |
| 1317 | + event: React.MouseEvent, | |
| 1318 | + threadRef: ScopedThreadRef, | |
| 1319 | + orderedProjectThreadKeys: readonly string[], | |
| 1320 | + ) => { | |
| 1321 | + const isMac = isMacPlatform(navigator.platform); | |
| 1322 | + const isModClick = isMac ? event.metaKey : event.ctrlKey; | |
| 1323 | + const isShiftClick = event.shiftKey; | |
| 1324 | + const threadKey = scopedThreadKey(threadRef); | |
| 1325 | + const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size; | |
| 914 | 1326 | |
| 915 | − const handlePickFolder = async () => { | |
| 916 | − const api = readNativeApi(); | |
| 917 | − if (!api || isPickingFolder) return; | |
| 918 | − setIsPickingFolder(true); | |
| 919 | − let pickedPath: string | null = null; | |
| 920 | − try { | |
| 921 | − pickedPath = await api.dialogs.pickFolder(); | |
| 922 | − } catch { | |
| 923 | − // Ignore picker failures and leave the current thread selection unchanged. | |
| 924 | − } | |
| 925 | − if (pickedPath) { | |
| 926 | − await addProjectFromPath(pickedPath); | |
| 927 | − } else if (!shouldBrowseForProjectImmediately) { | |
| 928 | − addProjectInputRef.current?.focus(); | |
| 929 | − } | |
| 930 | − setIsPickingFolder(false); | |
| 931 | − }; | |
| 932 | − | |
| 933 | − const handleStartAddProject = () => { | |
| 934 | − setAddProjectError(null); | |
| 935 | − if (shouldBrowseForProjectImmediately) { | |
| 936 | − void handlePickFolder(); | |
| 937 | − return; | |
| 938 | − } | |
| 939 | − setAddingProject((prev) => !prev); | |
| 940 | − }; | |
| 941 | − | |
| 942 | − const cancelRename = useCallback(() => { | |
| 943 | − setRenamingThreadId(null); | |
| 944 | − renamingInputRef.current = null; | |
| 945 | − }, []); | |
| 1327 | + if (isModClick) { | |
| 1328 | + event.preventDefault(); | |
| 1329 | + toggleThreadSelection(threadKey); | |
| 1330 | + return; | |
| 1331 | + } | |
| 946 | 1332 | |
| 947 | − const handleRenamingInputMount = useCallback((element: HTMLInputElement | null) => { | |
| 948 | − if (element && renamingInputRef.current !== element) { | |
| 949 | − renamingInputRef.current = element; | |
| 950 | − element.focus(); | |
| 951 | − element.select(); | |
| 952 | − return; | |
| 953 | − } | |
| 954 | − if (element === null && renamingInputRef.current !== null) { | |
| 955 | − renamingInputRef.current = null; | |
| 956 | − } | |
| 957 | − }, []); | |
| 1333 | + if (isShiftClick) { | |
| 1334 | + event.preventDefault(); | |
| 1335 | + rangeSelectTo(threadKey, orderedProjectThreadKeys); | |
| 1336 | + return; | |
| 1337 | + } | |
| 958 | 1338 | |
| 959 | − const hasRenameCommitted = useCallback(() => renamingCommittedRef.current, []); | |
| 960 | − const markRenameCommitted = useCallback(() => { | |
| 961 | − renamingCommittedRef.current = true; | |
| 962 | − }, []); | |
| 1339 | + if (currentSelectionCount > 0) { | |
| 1340 | + clearSelection(); | |
| 1341 | + } | |
| 1342 | + setSelectionAnchor(threadKey); | |
| 1343 | + void router.navigate({ | |
| 1344 | + to: "/$environmentId/$threadId", | |
| 1345 | + params: buildThreadRouteParams(threadRef), | |
| 1346 | + }); | |
| 1347 | + }, | |
| 1348 | + [clearSelection, rangeSelectTo, router, setSelectionAnchor, toggleThreadSelection], | |
| 1349 | + ); | |
| 963 | 1350 | |
| 964 | − const cancelProjectRename = useCallback(() => { | |
| 965 | − setRenamingProjectId(null); | |
| 966 | − projectRenamingInputRef.current = null; | |
| 967 | − }, []); | |
| 1351 | + const handleMultiSelectContextMenu = useCallback( | |
| 1352 | + async (position: { x: number; y: number }) => { | |
| 1353 | + const api = readLocalApi(); | |
| 1354 | + if (!api) return; | |
| 1355 | + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; | |
| 1356 | + if (threadKeys.length === 0) return; | |
| 1357 | + const count = threadKeys.length; | |
| 968 | 1358 | |
| 969 | − const commitRename = useCallback( | |
| 970 | − async (threadId: ThreadId, newTitle: string, originalTitle: string) => { | |
| 971 | − const finishRename = () => { | |
| 972 | − setRenamingThreadId((current) => { | |
| 973 | − if (current !== threadId) return current; | |
| 974 | − renamingInputRef.current = null; | |
| 975 | − return null; | |
| 976 | − }); | |
| 977 | − }; | |
| 1359 | + const clicked = await api.contextMenu.show( | |
| 1360 | + [ | |
| 1361 | + { id: "mark-unread", label: `Mark unread (${count})` }, | |
| 1362 | + { id: "delete", label: `Delete (${count})`, destructive: true }, | |
| 1363 | + ], | |
| 1364 | + position, | |
| 1365 | + ); | |
| 978 | 1366 | |
| 979 | − const trimmed = newTitle.trim(); | |
| 980 | − if (trimmed.length === 0) { | |
| 981 | − toastManager.add({ | |
| 982 | − type: "warning", | |
| 983 | − title: "Thread title cannot be empty", | |
| 984 | − }); | |
| 985 | − finishRename(); | |
| 1367 | + if (clicked === "mark-unread") { | |
| 1368 | + for (const threadKey of threadKeys) { | |
| 1369 | + const thread = sidebarThreadByKey.get(threadKey); | |
| 1370 | + markThreadUnread(threadKey, thread?.latestTurn?.completedAt); | |
| 1371 | + } | |
| 1372 | + clearSelection(); | |
| 986 | 1373 | return; |
| 987 | 1374 | } |
| 988 | − if (trimmed === originalTitle) { | |
| 989 | − finishRename(); | |
| 990 | − return; | |
| 1375 | + | |
| 1376 | + if (clicked !== "delete") return; | |
| 1377 | + | |
| 1378 | + if (appSettingsConfirmThreadDelete) { | |
| 1379 | + const confirmed = await api.dialogs.confirm( | |
| 1380 | + [ | |
| 1381 | + `Delete ${count} thread${count === 1 ? "" : "s"}?`, | |
| 1382 | + "This permanently clears conversation history for these threads.", | |
| 1383 | + ].join("\n"), | |
| 1384 | + ); | |
| 1385 | + if (!confirmed) return; | |
| 991 | 1386 | } |
| 992 | − const api = readNativeApi(); | |
| 993 | − if (!api) { | |
| 994 | − finishRename(); | |
| 995 | − return; | |
| 1387 | + | |
| 1388 | + const deletedThreadKeys = new Set(threadKeys); | |
| 1389 | + for (const threadKey of threadKeys) { | |
| 1390 | + const thread = sidebarThreadByKey.get(threadKey); | |
| 1391 | + if (!thread) continue; | |
| 1392 | + await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { | |
| 1393 | + deletedThreadKeys, | |
| 1394 | + }); | |
| 996 | 1395 | } |
| 1396 | + removeFromSelection(threadKeys); | |
| 1397 | + }, | |
| 1398 | + [ | |
| 1399 | + appSettingsConfirmThreadDelete, | |
| 1400 | + clearSelection, | |
| 1401 | + deleteThread, | |
| 1402 | + markThreadUnread, | |
| 1403 | + removeFromSelection, | |
| 1404 | + sidebarThreadByKey, | |
| 1405 | + ], | |
| 1406 | + ); | |
| 1407 | + | |
| 1408 | + const handleCreateThreadClick = useCallback( | |
| 1409 | + (event: React.MouseEvent<HTMLButtonElement>) => { | |
| 1410 | + event.preventDefault(); | |
| 1411 | + event.stopPropagation(); | |
| 1412 | + const currentRouteParams = | |
| 1413 | + router.state.matches[router.state.matches.length - 1]?.params ?? {}; | |
| 1414 | + const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); | |
| 1415 | + const currentActiveThread = | |
| 1416 | + currentRouteTarget?.kind === "server" | |
| 1417 | + ? (selectThreadByRef(useStore.getState(), currentRouteTarget.threadRef) ?? null) | |
| 1418 | + : null; | |
| 1419 | + const draftStore = useComposerDraftStore.getState(); | |
| 1420 | + const currentActiveDraftThread = | |
| 1421 | + currentRouteTarget?.kind === "server" | |
| 1422 | + ? (draftStore.getDraftThread(currentRouteTarget.threadRef) ?? null) | |
| 1423 | + : currentRouteTarget?.kind === "draft" | |
| 1424 | + ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null) | |
| 1425 | + : null; | |
| 1426 | + const seedContext = resolveSidebarNewThreadSeedContext({ | |
| 1427 | + projectId: project.id, | |
| 1428 | + defaultEnvMode: resolveSidebarNewThreadEnvMode({ | |
| 1429 | + defaultEnvMode: defaultThreadEnvMode, | |
| 1430 | + }), | |
| 1431 | + activeThread: | |
| 1432 | + currentActiveThread && currentActiveThread.projectId === project.id | |
| 1433 | + ? { | |
| 1434 | + projectId: currentActiveThread.projectId, | |
| 1435 | + branch: currentActiveThread.branch, | |
| 1436 | + worktreePath: currentActiveThread.worktreePath, | |
| 1437 | + } | |
| 1438 | + : null, | |
| 1439 | + activeDraftThread: | |
| 1440 | + currentActiveDraftThread && currentActiveDraftThread.projectId === project.id | |
| 1441 | + ? { | |
| 1442 | + projectId: currentActiveDraftThread.projectId, | |
| 1443 | + branch: currentActiveDraftThread.branch, | |
| 1444 | + worktreePath: currentActiveDraftThread.worktreePath, | |
| 1445 | + envMode: currentActiveDraftThread.envMode, | |
| 1446 | + } | |
| 1447 | + : null, | |
| 1448 | + }); | |
| 1449 | + void handleNewThread(scopeProjectRef(project.environmentId, project.id), { | |
| 1450 | + ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), | |
| 1451 | + ...(seedContext.worktreePath !== undefined | |
| 1452 | + ? { worktreePath: seedContext.worktreePath } | |
| 1453 | + : {}), | |
| 1454 | + envMode: seedContext.envMode, | |
| 1455 | + }); | |
| 1456 | + }, | |
| 1457 | + [defaultThreadEnvMode, handleNewThread, project.environmentId, project.id, router], | |
| 1458 | + ); | |
| 1459 | + | |
| 1460 | + const attemptArchiveThread = useCallback( | |
| 1461 | + async (threadRef: ScopedThreadRef) => { | |
| 997 | 1462 | try { |
| 998 | − await api.orchestration.dispatchCommand({ | |
| 999 | − type: "thread.meta.update", | |
| 1000 | − commandId: newCommandId(), | |
| 1001 | − threadId, | |
| 1002 | − title: trimmed, | |
| 1003 | − }); | |
| 1463 | + await archiveThread(threadRef); | |
| 1004 | 1464 | } catch (error) { |
| 1005 | 1465 | toastManager.add({ |
| 1006 | 1466 | type: "error", |
| 1007 | − title: "Failed to rename thread", | |
| 1467 | + title: "Failed to archive thread", | |
| 1008 | 1468 | description: error instanceof Error ? error.message : "An error occurred.", |
| 1009 | 1469 | }); |
| 1010 | 1470 | } |
| 1011 | − finishRename(); | |
| 1012 | 1471 | }, |
| 1013 | − [], | |
| 1472 | + [archiveThread], | |
| 1014 | 1473 | ); |
| 1015 | 1474 | |
| 1016 | − const commitProjectRename = useCallback( | |
| 1017 | − async (projectId: ProjectId, newTitle: string, originalTitle: string) => { | |
| 1475 | + const cancelRename = useCallback(() => { | |
| 1476 | + setRenamingThreadKey(null); | |
| 1477 | + renamingInputRef.current = null; | |
| 1478 | + }, []); | |
| 1479 | + | |
| 1480 | + const commitRename = useCallback( | |
| 1481 | + async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => { | |
| 1482 | + const threadKey = scopedThreadKey(threadRef); | |
| 1018 | 1483 | const finishRename = () => { |
| 1019 | − setRenamingProjectId((current) => { | |
| 1020 | − if (current !== projectId) return current; | |
| 1021 | − projectRenamingInputRef.current = null; | |
| 1484 | + setRenamingThreadKey((current) => { | |
| 1485 | + if (current !== threadKey) return current; | |
| 1486 | + renamingInputRef.current = null; | |
| 1022 | 1487 | return null; |
| 1023 | 1488 | }); |
| 1024 | 1489 | }; |
| @@ -1027,7 +1492,7 @@ export default function Sidebar() { · 2 unmodified lines | ||
| 1027 | 1492 | if (trimmed.length === 0) { |
| 1028 | 1493 | toastManager.add({ |
| 1029 | 1494 | type: "warning", |
| 1030 | − title: "Project title cannot be empty", | |
| 1495 | + title: "Thread title cannot be empty", | |
| 1031 | 1496 | }); |
| 1032 | 1497 | finishRename(); |
| 1033 | 1498 | return; |
| @@ -1036,22 +1501,22 @@ export default function Sidebar() { · 2 unmodified lines | ||
| 1036 | 1501 | finishRename(); |
| 1037 | 1502 | return; |
| 1038 | 1503 | } |
| 1039 | − const api = readNativeApi(); | |
| 1504 | + const api = readEnvironmentApi(threadRef.environmentId); | |
| 1040 | 1505 | if (!api) { |
| 1041 | 1506 | finishRename(); |
| 1042 | 1507 | return; |
| 1043 | 1508 | } |
| 1044 | 1509 | try { |
| 1045 | 1510 | await api.orchestration.dispatchCommand({ |
| 1046 | − type: "project.meta.update", | |
| 1511 | + type: "thread.meta.update", | |
| 1047 | 1512 | commandId: newCommandId(), |
| 1048 | − projectId, | |
| 1513 | + threadId: threadRef.threadId, | |
| 1049 | 1514 | title: trimmed, |
| 1050 | 1515 | }); |
| 1051 | 1516 | } catch (error) { |
| 1052 | 1517 | toastManager.add({ |
| 1053 | 1518 | type: "error", |
| 1054 | − title: "Failed to rename project", | |
| 1519 | + title: "Failed to rename thread", | |
| 1055 | 1520 | description: error instanceof Error ? error.message : "An error occurred.", |
| 1056 | 1521 | }); |
| 1057 | 1522 | } |
| @@ -1060,50 +1525,19 @@ export default function Sidebar() { · 2 unmodified lines | ||
| 1060 | 1525 | [], |
| 1061 | 1526 | ); |
| 1062 | 1527 | |
| 1063 | − const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ | |
| 1064 | − threadId: ThreadId; | |
| 1065 | − }>({ | |
| 1066 | − onCopy: (ctx) => { | |
| 1067 | − toastManager.add({ | |
| 1068 | − type: "success", | |
| 1069 | − title: "Thread ID copied", | |
| 1070 | − description: ctx.threadId, | |
| 1071 | − }); | |
| 1072 | − }, | |
| 1073 | − onError: (error) => { | |
| 1074 | − toastManager.add({ | |
| 1075 | − type: "error", | |
| 1076 | − title: "Failed to copy thread ID", | |
| 1077 | − description: error instanceof Error ? error.message : "An error occurred.", | |
| 1078 | − }); | |
| 1079 | − }, | |
| 1080 | − }); | |
| 1081 | − const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ | |
| 1082 | − path: string; | |
| 1083 | − }>({ | |
| 1084 | − onCopy: (ctx) => { | |
| 1085 | − toastManager.add({ | |
| 1086 | − type: "success", | |
| 1087 | − title: "Path copied", | |
| 1088 | − description: ctx.path, | |
| 1089 | − }); | |
| 1090 | − }, | |
| 1091 | − onError: (error) => { | |
| 1092 | − toastManager.add({ | |
| 1093 | − type: "error", | |
| 1094 | − title: "Failed to copy path", | |
| 1095 | − description: error instanceof Error ? error.message : "An error occurred.", | |
| 1096 | − }); | |
| 1097 | − }, | |
| 1098 | − }); | |
| 1099 | 1528 | const handleThreadContextMenu = useCallback( |
| 1100 | − async (threadId: ThreadId, position: { x: number; y: number }) => { | |
| 1101 | − const api = readNativeApi(); | |
| 1529 | + async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { | |
| 1530 | + const api = readLocalApi(); | |
| 1102 | 1531 | if (!api) return; |
| 1103 | − const thread = sidebarThreadsById[threadId]; | |
| 1532 | + const threadKey = scopedThreadKey(threadRef); | |
| 1533 | + const thread = | |
| 1534 | + projectThreads.find( | |
| 1535 | + (projectThread) => | |
| 1536 | + projectThread.environmentId === threadRef.environmentId && | |
| 1537 | + projectThread.id === threadRef.threadId, | |
| 1538 | + ) ?? null; | |
| 1104 | 1539 | if (!thread) return; |
| 1105 | − const threadWorkspacePath = | |
| 1106 | − thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; | |
| 1540 | + const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null; | |
| 1107 | 1541 | const clicked = await api.contextMenu.show( |
| 1108 | 1542 | [ |
| 1109 | 1543 | { id: "rename", label: "Rename thread" }, |
| @@ -1116,16 +1550,14 @@ export default function Sidebar() { · 6 unmodified lines | ||
| 1116 | 1550 | ); |
| 1117 | 1551 | |
| 1118 | 1552 | if (clicked === "rename") { |
| 1119 | − setRenamingProjectId(null); | |
| 1120 | − projectRenamingInputRef.current = null; | |
| 1121 | − setRenamingThreadId(threadId); | |
| 1553 | + setRenamingThreadKey(threadKey); | |
| 1122 | 1554 | setRenamingTitle(thread.title); |
| 1123 | 1555 | renamingCommittedRef.current = false; |
| 1124 | 1556 | return; |
| 1125 | 1557 | } |
| 1126 | 1558 | |
| 1127 | 1559 | if (clicked === "mark-unread") { |
| 1128 | − markThreadUnread(threadId, thread.latestTurn?.completedAt); | |
| 1560 | + markThreadUnread(threadKey, thread.latestTurn?.completedAt); | |
| 1129 | 1561 | return; |
| 1130 | 1562 | } |
| 1131 | 1563 | if (clicked === "copy-path") { |
| @@ -1141,11 +1573,11 @@ export default function Sidebar() { · 9 unmodified lines | ||
| 1141 | 1573 | return; |
| 1142 | 1574 | } |
| 1143 | 1575 | if (clicked === "copy-thread-id") { |
| 1144 | − copyThreadIdToClipboard(threadId, { threadId }); | |
| 1576 | + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); | |
| 1145 | 1577 | return; |
| 1146 | 1578 | } |
| 1147 | 1579 | if (clicked !== "delete") return; |
| 1148 | − if (appSettings.confirmThreadDelete) { | |
| 1580 | + if (appSettingsConfirmThreadDelete) { | |
| 1149 | 1581 | const confirmed = await api.dialogs.confirm( |
| 1150 | 1582 | [ |
| 1151 | 1583 | `Delete thread "${thread.title}"?`, |
| @@ -1156,198 +1588,903 @@ export default function Sidebar() { · 4 unmodified lines | ||
| 1156 | 1588 | return; |
| 1157 | 1589 | } |
| 1158 | 1590 | } |
| 1159 | − await deleteThread(threadId); | |
| 1591 | + await deleteThread(threadRef); | |
| 1160 | 1592 | }, |
| 1161 | 1593 | [ |
| 1162 | − appSettings.confirmThreadDelete, | |
| 1594 | + appSettingsConfirmThreadDelete, | |
| 1163 | 1595 | copyPathToClipboard, |
| 1164 | 1596 | copyThreadIdToClipboard, |
| 1165 | 1597 | deleteThread, |
| 1166 | 1598 | markThreadUnread, |
| 1167 | − projectCwdById, | |
| 1168 | − sidebarThreadsById, | |
| 1599 | + project.cwd, | |
| 1600 | + projectThreads, | |
| 1169 | 1601 | ], |
| 1170 | 1602 | ); |
| 1171 | 1603 | |
| 1172 | − const handleMultiSelectContextMenu = useCallback( | |
| 1173 | − async (position: { x: number; y: number }) => { | |
| 1174 | − const api = readNativeApi(); | |
| 1175 | − if (!api) return; | |
| 1176 | − const ids = [...selectedThreadIds]; | |
| 1177 | − if (ids.length === 0) return; | |
| 1178 | − const count = ids.length; | |
| 1179 | − | |
| 1180 | − const clicked = await api.contextMenu.show( | |
| 1181 | − [ | |
| 1182 | − { id: "mark-unread", label: `Mark unread (${count})` }, | |
| 1183 | − { id: "delete", label: `Delete (${count})`, destructive: true }, | |
| 1184 | − ], | |
| 1185 | − position, | |
| 1186 | − ); | |
| 1187 | − | |
| 1188 | − if (clicked === "mark-unread") { | |
| 1189 | − for (const id of ids) { | |
| 1190 | − const thread = sidebarThreadsById[id]; | |
| 1191 | − markThreadUnread(id, thread?.latestTurn?.completedAt); | |
| 1192 | − } | |
| 1193 | − clearSelection(); | |
| 1194 | − return; | |
| 1195 | − } | |
| 1196 | − | |
| 1197 | − if (clicked !== "delete") return; | |
| 1198 | − | |
| 1199 | − if (appSettings.confirmThreadDelete) { | |
| 1200 | − const confirmed = await api.dialogs.confirm( | |
| 1201 | − [ | |
| 1202 | − `Delete ${count} thread${count === 1 ? "" : "s"}?`, | |
| 1203 | − "This permanently clears conversation history for these threads.", | |
| 1204 | − ].join("\n"), | |
| 1205 | − ); | |
| 1206 | − if (!confirmed) return; | |
| 1207 | − } | |
| 1208 | − | |
| 1209 | − const deletedIds = new Set<ThreadId>(ids); | |
| 1210 | − for (const id of ids) { | |
| 1211 | − await deleteThread(id, { deletedThreadIds: deletedIds }); | |
| 1212 | − } | |
| 1213 | − removeFromSelection(ids); | |
| 1214 | − }, | |
| 1215 | − [ | |
| 1216 | − appSettings.confirmThreadDelete, | |
| 1217 | − clearSelection, | |
| 1218 | − deleteThread, | |
| 1219 | − markThreadUnread, | |
| 1220 | − removeFromSelection, | |
| 1221 | − selectedThreadIds, | |
| 1222 | − sidebarThreadsById, | |
| 1223 | − ], | |
| 1604 | + return ( | |
| 1605 | + <> | |
| 1606 | + <div className="group/project-header relative"> | |
| 1607 | + <SidebarMenuButton | |
| 1608 | + ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined} | |
| 1609 | + size="sm" | |
| 1610 | + className={`gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${ | |
| 1611 | + isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer" | |
| 1612 | + }`} | |
| 1613 | + {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})} | |
| 1614 | + {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})} | |
| 1615 | + onPointerDownCapture={handleProjectButtonPointerDownCapture} | |
| 1616 | + onClick={handleProjectButtonClick} | |
| 1617 | + onKeyDown={handleProjectButtonKeyDown} | |
| 1618 | + onContextMenu={handleProjectButtonContextMenu} | |
| 1619 | + > | |
| 1620 | + {!projectExpanded && projectStatus ? ( | |
| 1621 | + <span | |
| 1622 | + aria-hidden="true" | |
| 1623 | + title={projectStatus.label} | |
| 1624 | + className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`} | |
| 1625 | + > | |
| 1626 | + <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0"> | |
| 1627 | + <span | |
| 1628 | + className={`size-[9px] rounded-full ${projectStatus.dotClass} ${ | |
| 1629 | + projectStatus.pulse ? "animate-pulse" : "" | |
| 1630 | + }`} | |
| 1631 | + /> | |
| 1632 | + </span> | |
| 1633 | + <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" /> | |
| 1634 | + </span> | |
| 1635 | + ) : ( | |
| 1636 | + <ChevronRightIcon | |
| 1637 | + className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${ | |
| 1638 | + projectExpanded ? "rotate-90" : "" | |
| 1639 | + }`} | |
| 1640 | + /> | |
| 1641 | + )} | |
| 1642 | + <ProjectFavicon cwd={project.cwd} /> | |
| 1643 | + <span className="flex-1 truncate text-xs font-medium text-foreground/90"> | |
| 1644 | + {project.name} | |
| 1645 | + </span> | |
| 1646 | + </SidebarMenuButton> | |
| 1647 | + <Tooltip> | |
| 1648 | + <TooltipTrigger | |
| 1649 | + render={ | |
| 1650 | + <SidebarMenuAction | |
| 1651 | + render={ | |
| 1652 | + <button | |
| 1653 | + type="button" | |
| 1654 | + aria-label={`Create new thread in ${project.name}`} | |
| 1655 | + data-testid="new-thread-button" | |
| 1656 | + /> | |
| 1657 | + } | |
| 1658 | + showOnHover | |
| 1659 | + className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" | |
| 1660 | + onClick={handleCreateThreadClick} | |
| 1661 | + > | |
| 1662 | + <SquarePenIcon className="size-3.5" /> | |
| 1663 | + </SidebarMenuAction> | |
| 1664 | + } | |
| 1665 | + /> | |
| 1666 | + <TooltipPopup side="top"> | |
| 1667 | + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} | |
| 1668 | + </TooltipPopup> | |
| 1669 | + </Tooltip> | |
| 1670 | + </div> | |
| 1671 | + | |
| 1672 | + <SidebarProjectThreadList | |
| 1673 | + projectKey={project.projectKey} | |
| 1674 | + projectExpanded={projectExpanded} | |
| 1675 | + hasOverflowingThreads={hasOverflowingThreads} | |
| 1676 | + hiddenThreadStatus={hiddenThreadStatus} | |
| 1677 | + orderedProjectThreadKeys={orderedProjectThreadKeys} | |
| 1678 | + renderedThreads={renderedThreads} | |
| 1679 | + showEmptyThreadState={showEmptyThreadState} | |
| 1680 | + shouldShowThreadPanel={shouldShowThreadPanel} | |
| 1681 | + isThreadListExpanded={isThreadListExpanded} | |
| 1682 | + projectCwd={project.cwd} | |
| 1683 | + activeRouteThreadKey={activeRouteThreadKey} | |
| 1684 | + threadJumpLabelByKey={threadJumpLabelByKey} | |
| 1685 | + appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} | |
| 1686 | + renamingThreadKey={renamingThreadKey} | |
| 1687 | + renamingTitle={renamingTitle} | |
| 1688 | + setRenamingTitle={setRenamingTitle} | |
| 1689 | + renamingInputRef={renamingInputRef} | |
| 1690 | + renamingCommittedRef={renamingCommittedRef} | |
| 1691 | + confirmingArchiveThreadKey={confirmingArchiveThreadKey} | |
| 1692 | + setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey} | |
| 1693 | + confirmArchiveButtonRefs={confirmArchiveButtonRefs} | |
| 1694 | + attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} | |
| 1695 | + handleThreadClick={handleThreadClick} | |
| 1696 | + navigateToThread={navigateToThread} | |
| 1697 | + handleMultiSelectContextMenu={handleMultiSelectContextMenu} | |
| 1698 | + handleThreadContextMenu={handleThreadContextMenu} | |
| 1699 | + clearSelection={clearSelection} | |
| 1700 | + commitRename={commitRename} | |
| 1701 | + cancelRename={cancelRename} | |
| 1702 | + attemptArchiveThread={attemptArchiveThread} | |
| 1703 | + openPrLink={openPrLink} | |
| 1704 | + expandThreadListForProject={expandThreadListForProject} | |
| 1705 | + collapseThreadListForProject={collapseThreadListForProject} | |
| 1706 | + /> | |
| 1707 | + </> | |
| 1224 | 1708 | ); |
| 1709 | +}); | |
| 1225 | 1710 | |
| 1226 | − const handleThreadClick = useCallback( | |
| 1227 | − (event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => { | |
| 1228 | − const isMac = isMacPlatform(navigator.platform); | |
| 1229 | − const isModClick = isMac ? event.metaKey : event.ctrlKey; | |
| 1230 | − const isShiftClick = event.shiftKey; | |
| 1231 | − | |
| 1232 | − if (isModClick) { | |
| 1233 | − event.preventDefault(); | |
| 1234 | − toggleThreadSelection(threadId); | |
| 1235 | − return; | |
| 1236 | − } | |
| 1237 | − | |
| 1238 | − if (isShiftClick) { | |
| 1239 | − event.preventDefault(); | |
| 1240 | − rangeSelectTo(threadId, orderedProjectThreadIds); | |
| 1241 | − return; | |
| 1242 | − } | |
| 1243 | − | |
| 1244 | − // Plain click — clear selection, set anchor for future shift-clicks, and navigate | |
| 1245 | − if (selectedThreadIds.size > 0) { | |
| 1246 | − clearSelection(); | |
| 1247 | − } | |
| 1248 | − setSelectionAnchor(threadId); | |
| 1249 | − void navigate({ | |
| 1250 | − to: "/$threadId", | |
| 1251 | − params: { threadId }, | |
| 1252 | − }); | |
| 1253 | − }, | |
| 1254 | − [ | |
| 1255 | − clearSelection, | |
| 1256 | − navigate, | |
| 1257 | − rangeSelectTo, | |
| 1258 | − selectedThreadIds.size, | |
| 1259 | − setSelectionAnchor, | |
| 1260 | − toggleThreadSelection, | |
| 1261 | − ], | |
| 1711 | +const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { | |
| 1712 | + return ( | |
| 1713 | + <SidebarMenuItem className="rounded-md"> | |
| 1714 | + <SidebarProjectItem {...props} /> | |
| 1715 | + </SidebarMenuItem> | |
| 1262 | 1716 | ); |
| 1717 | +}); | |
| 1263 | 1718 | |
| 1264 | − const navigateToThread = useCallback( | |
| 1265 | − (threadId: ThreadId) => { | |
| 1266 | − if (selectedThreadIds.size > 0) { | |
| 1267 | − clearSelection(); | |
| 1268 | − } | |
| 1269 | − setSelectionAnchor(threadId); | |
| 1270 | − void navigate({ | |
| 1271 | − to: "/$threadId", | |
| 1272 | − params: { threadId }, | |
| 1273 | − }); | |
| 1274 | − }, | |
| 1275 | − [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor], | |
| 1719 | +function T3Wordmark() { | |
| 1720 | + return ( | |
| 1721 | + <svg | |
| 1722 | + aria-label="T3" | |
| 1723 | + className="h-2.5 w-auto shrink-0 text-foreground" | |
| 1724 | + viewBox="15.5309 37 94.3941 56.96" | |
| 1725 | + xmlns="http://www.w3.org/2000/svg" | |
| 1726 | + > | |
| 1727 | + <path | |
| 1728 | + d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" | |
| 1729 | + fill="currentColor" | |
| 1730 | + /> | |
| 1731 | + </svg> | |
| 1276 | 1732 | ); |
| 1733 | +} | |
| 1277 | 1734 | |
| 1278 | − const handleProjectContextMenu = useCallback( | |
| 1279 | − async (projectId: ProjectId, position: { x: number; y: number }) => { | |
| 1280 | − const api = readNativeApi(); | |
| 1281 | − if (!api) return; | |
| 1282 | − const project = projects.find((entry) => entry.id === projectId); | |
| 1283 | − if (!project) return; | |
| 1735 | +type SortableProjectHandleProps = Pick< | |
| 1736 | + ReturnType<typeof useSortable>, | |
| 1737 | + "attributes" | "listeners" | "setActivatorNodeRef" | |
| 1738 | +>; | |
| 1284 | 1739 | |
| 1285 | − const clicked = await api.contextMenu.show( | |
| 1286 | − [ | |
| 1287 | − { id: "rename", label: "Rename project" }, | |
| 1288 | − { id: "copy-path", label: "Copy Project Path" }, | |
| 1289 | − { id: "delete", label: "Remove project", destructive: true }, | |
| 1290 | − ], | |
| 1291 | − position, | |
| 1292 | − ); | |
| 1293 | − if (clicked === "rename") { | |
| 1294 | − setRenamingThreadId(null); | |
| 1295 | − renamingInputRef.current = null; | |
| 1296 | − setRenamingProjectId(projectId); | |
| 1297 | − setRenamingProjectTitle(project.name); | |
| 1298 | − projectRenamingCommittedRef.current = false; | |
| 1299 | − return; | |
| 1740 | +function ProjectSortMenu({ | |
| 1741 | + projectSortOrder, | |
| 1742 | + threadSortOrder, | |
| 1743 | + onProjectSortOrderChange, | |
| 1744 | + onThreadSortOrderChange, | |
| 1745 | +}: { | |
| 1746 | + projectSortOrder: SidebarProjectSortOrder; | |
| 1747 | + threadSortOrder: SidebarThreadSortOrder; | |
| 1748 | + onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; | |
| 1749 | + onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; | |
| 1750 | +}) { | |
| 1751 | + return ( | |
| 1752 | + <Menu> | |
| 1753 | + <Tooltip> | |
| 1754 | + <TooltipTrigger | |
| 1755 | + render={ | |
| 1756 | + <MenuTrigger className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" /> | |
| 1757 | + } | |
| 1758 | + > | |
| 1759 | + <ArrowUpDownIcon className="size-3.5" /> | |
| 1760 | + </TooltipTrigger> | |
| 1761 | + <TooltipPopup side="right">Sort projects</TooltipPopup> | |
| 1762 | + </Tooltip> | |
| 1763 | + <MenuPopup align="end" side="bottom" className="min-w-44"> | |
| 1764 | + <MenuGroup> | |
| 1765 | + <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground"> | |
| 1766 | + Sort projects | |
| 1767 | + </div> | |
| 1768 | + <MenuRadioGroup | |
| 1769 | + value={projectSortOrder} | |
| 1770 | + onValueChange={(value) => { | |
| 1771 | + onProjectSortOrderChange(value as SidebarProjectSortOrder); | |
| 1772 | + }} | |
| 1773 | + > | |
| 1774 | + {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( | |
| 1775 | + ([value, label]) => ( | |
| 1776 | + <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs"> | |
| 1777 | + {label} | |
| 1778 | + </MenuRadioItem> | |
| 1779 | + ), | |
| 1780 | + )} | |
| 1781 | + </MenuRadioGroup> | |
| 1782 | + </MenuGroup> | |
| 1783 | + <MenuGroup> | |
| 1784 | + <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground"> | |
| 1785 | + Sort threads | |
| 1786 | + </div> | |
| 1787 | + <MenuRadioGroup | |
| 1788 | + value={threadSortOrder} | |
| 1789 | + onValueChange={(value) => { | |
| 1790 | + onThreadSortOrderChange(value as SidebarThreadSortOrder); | |
| 1791 | + }} | |
| 1792 | + > | |
| 1793 | + {( | |
| 1794 | + Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> | |
| 1795 | + ).map(([value, label]) => ( | |
| 1796 | + <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs"> | |
| 1797 | + {label} | |
| 1798 | + </MenuRadioItem> | |
| 1799 | + ))} | |
| 1800 | + </MenuRadioGroup> | |
| 1801 | + </MenuGroup> | |
| 1802 | + </MenuPopup> | |
| 1803 | + </Menu> | |
| 1804 | + ); | |
| 1805 | +} | |
| 1806 | + | |
| 1807 | +function SortableProjectItem({ | |
| 1808 | + projectId, | |
| 1809 | + disabled = false, | |
| 1810 | + children, | |
| 1811 | +}: { | |
| 1812 | + projectId: string; | |
| 1813 | + disabled?: boolean; | |
| 1814 | + children: (handleProps: SortableProjectHandleProps) => React.ReactNode; | |
| 1815 | +}) { | |
| 1816 | + const { | |
| 1817 | + attributes, | |
| 1818 | + listeners, | |
| 1819 | + setActivatorNodeRef, | |
| 1820 | + setNodeRef, | |
| 1821 | + transform, | |
| 1822 | + transition, | |
| 1823 | + isDragging, | |
| 1824 | + isOver, | |
| 1825 | + } = useSortable({ id: projectId, disabled }); | |
| 1826 | + return ( | |
| 1827 | + <li | |
| 1828 | + ref={setNodeRef} | |
| 1829 | + style={{ | |
| 1830 | + transform: CSS.Translate.toString(transform), | |
| 1831 | + transition, | |
| 1832 | + }} | |
| 1833 | + className={`group/menu-item relative rounded-md ${ | |
| 1834 | + isDragging ? "z-20 opacity-80" : "" | |
| 1835 | + } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`} | |
| 1836 | + data-sidebar="menu-item" | |
| 1837 | + data-slot="sidebar-menu-item" | |
| 1838 | + > | |
| 1839 | + {children({ attributes, listeners, setActivatorNodeRef })} | |
| 1840 | + </li> | |
| 1841 | + ); | |
| 1842 | +} | |
| 1843 | + | |
| 1844 | +const SidebarChromeHeader = memo(function SidebarChromeHeader({ | |
| 1845 | + isElectron, | |
| 1846 | +}: { | |
| 1847 | + isElectron: boolean; | |
| 1848 | +}) { | |
| 1849 | + const wordmark = ( | |
| 1850 | + <div className="flex items-center gap-2"> | |
| 1851 | + <SidebarTrigger className="shrink-0 md:hidden" /> | |
| 1852 | + <Tooltip> | |
| 1853 | + <TooltipTrigger | |
| 1854 | + render={ | |
| 1855 | + <Link | |
| 1856 | + aria-label="Go to threads" | |
| 1857 | + className="ml-1 flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md outline-hidden ring-ring transition-colors hover:text-foreground focus-visible:ring-2" | |
| 1858 | + to="/" | |
| 1859 | + > | |
| 1860 | + <T3Wordmark /> | |
| 1861 | + <span className="truncate text-sm font-medium tracking-tight text-muted-foreground"> | |
| 1862 | + Code | |
| 1863 | + </span> | |
| 1864 | + <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground/60"> | |
| 1865 | + {APP_STAGE_LABEL} | |
| 1866 | + </span> | |
| 1867 | + </Link> | |
| 1868 | + } | |
| 1869 | + /> | |
| 1870 | + <TooltipPopup side="bottom" sideOffset={2}> | |
| 1871 | + Version {APP_VERSION} | |
| 1872 | + </TooltipPopup> | |
| 1873 | + </Tooltip> | |
| 1874 | + </div> | |
| 1875 | + ); | |
| 1876 | + | |
| 1877 | + return isElectron ? ( | |
| 1878 | + <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]"> | |
| 1879 | + {wordmark} | |
| 1880 | + </SidebarHeader> | |
| 1881 | + ) : ( | |
| 1882 | + <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">{wordmark}</SidebarHeader> | |
| 1883 | + ); | |
| 1884 | +}); | |
| 1885 | + | |
| 1886 | +const SidebarChromeFooter = memo(function SidebarChromeFooter() { | |
| 1887 | + const navigate = useNavigate(); | |
| 1888 | + const handleSettingsClick = useCallback(() => { | |
| 1889 | + void navigate({ to: "/settings" }); | |
| 1890 | + }, [navigate]); | |
| 1891 | + | |
| 1892 | + return ( | |
| 1893 | + <SidebarFooter className="p-2"> | |
| 1894 | + <SidebarUpdatePill /> | |
| 1895 | + <SidebarMenu> | |
| 1896 | + <SidebarMenuItem> | |
| 1897 | + <SidebarMenuButton | |
| 1898 | + size="sm" | |
| 1899 | + className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground" | |
| 1900 | + onClick={handleSettingsClick} | |
| 1901 | + > | |
| 1902 | + <SettingsIcon className="size-3.5" /> | |
| 1903 | + <span className="text-xs">Settings</span> | |
| 1904 | + </SidebarMenuButton> | |
| 1905 | + </SidebarMenuItem> | |
| 1906 | + </SidebarMenu> | |
| 1907 | + </SidebarFooter> | |
| 1908 | + ); | |
| 1909 | +}); | |
| 1910 | + | |
| 1911 | +interface SidebarProjectsContentProps { | |
| 1912 | + showArm64IntelBuildWarning: boolean; | |
| 1913 | + arm64IntelBuildWarningDescription: string | null; | |
| 1914 | + desktopUpdateButtonAction: "download" | "install" | "none"; | |
| 1915 | + desktopUpdateButtonDisabled: boolean; | |
| 1916 | + handleDesktopUpdateButtonClick: () => void; | |
| 1917 | + projectSortOrder: SidebarProjectSortOrder; | |
| 1918 | + threadSortOrder: SidebarThreadSortOrder; | |
| 1919 | + updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"]; | |
| 1920 | + shouldShowProjectPathEntry: boolean; | |
| 1921 | + handleStartAddProject: () => void; | |
| 1922 | + isElectron: boolean; | |
| 1923 | + isPickingFolder: boolean; | |
| 1924 | + isAddingProject: boolean; | |
| 1925 | + handlePickFolder: () => Promise<void>; | |
| 1926 | + addProjectInputRef: React.RefObject<HTMLInputElement | null>; | |
| 1927 | + addProjectError: string | null; | |
| 1928 | + newCwd: string; | |
| 1929 | + setNewCwd: React.Dispatch<React.SetStateAction<string>>; | |
| 1930 | + setAddProjectError: React.Dispatch<React.SetStateAction<string | null>>; | |
| 1931 | + handleAddProject: () => void; | |
| 1932 | + setAddingProject: React.Dispatch<React.SetStateAction<boolean>>; | |
| 1933 | + canAddProject: boolean; | |
| 1934 | + isManualProjectSorting: boolean; | |
| 1935 | + projectDnDSensors: ReturnType<typeof useSensors>; | |
| 1936 | + projectCollisionDetection: CollisionDetection; | |
| 1937 | + handleProjectDragStart: (event: DragStartEvent) => void; | |
| 1938 | + handleProjectDragEnd: (event: DragEndEvent) => void; | |
| 1939 | + handleProjectDragCancel: (event: DragCancelEvent) => void; | |
| 1940 | + handleNewThread: ReturnType<typeof useHandleNewThread>["handleNewThread"]; | |
| 1941 | + archiveThread: ReturnType<typeof useThreadActions>["archiveThread"]; | |
| 1942 | + deleteThread: ReturnType<typeof useThreadActions>["deleteThread"]; | |
| 1943 | + sortedProjects: readonly SidebarProjectSnapshot[]; | |
| 1944 | + expandedThreadListsByProject: ReadonlySet<string>; | |
| 1945 | + activeRouteProjectKey: string | null; | |
| 1946 | + routeThreadKey: string | null; | |
| 1947 | + newThreadShortcutLabel: string | null; | |
| 1948 | + threadJumpLabelByKey: ReadonlyMap<string, string>; | |
| 1949 | + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; | |
| 1950 | + expandThreadListForProject: (projectKey: string) => void; | |
| 1951 | + collapseThreadListForProject: (projectKey: string) => void; | |
| 1952 | + dragInProgressRef: React.RefObject<boolean>; | |
| 1953 | + suppressProjectClickAfterDragRef: React.RefObject<boolean>; | |
| 1954 | + suppressProjectClickForContextMenuRef: React.RefObject<boolean>; | |
| 1955 | + attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; | |
| 1956 | + projectsLength: number; | |
| 1957 | +} | |
| 1958 | + | |
| 1959 | +const SidebarProjectsContent = memo(function SidebarProjectsContent( | |
| 1960 | + props: SidebarProjectsContentProps, | |
| 1961 | +) { | |
| 1962 | + const { | |
| 1963 | + showArm64IntelBuildWarning, | |
| 1964 | + arm64IntelBuildWarningDescription, | |
| 1965 | + desktopUpdateButtonAction, | |
| 1966 | + desktopUpdateButtonDisabled, | |
| 1967 | + handleDesktopUpdateButtonClick, | |
| 1968 | + projectSortOrder, | |
| 1969 | + threadSortOrder, | |
| 1970 | + updateSettings, | |
| 1971 | + shouldShowProjectPathEntry, | |
| 1972 | + handleStartAddProject, | |
| 1973 | + isElectron, | |
| 1974 | + isPickingFolder, | |
| 1975 | + isAddingProject, | |
| 1976 | + handlePickFolder, | |
| 1977 | + addProjectInputRef, | |
| 1978 | + addProjectError, | |
| 1979 | + newCwd, | |
| 1980 | + setNewCwd, | |
| 1981 | + setAddProjectError, | |
| 1982 | + handleAddProject, | |
| 1983 | + setAddingProject, | |
| 1984 | + canAddProject, | |
| 1985 | + isManualProjectSorting, | |
| 1986 | + projectDnDSensors, | |
| 1987 | + projectCollisionDetection, | |
| 1988 | + handleProjectDragStart, | |
| 1989 | + handleProjectDragEnd, | |
| 1990 | + handleProjectDragCancel, | |
| 1991 | + handleNewThread, | |
| 1992 | + archiveThread, | |
| 1993 | + deleteThread, | |
| 1994 | + sortedProjects, | |
| 1995 | + expandedThreadListsByProject, | |
| 1996 | + activeRouteProjectKey, | |
| 1997 | + routeThreadKey, | |
| 1998 | + newThreadShortcutLabel, | |
| 1999 | + threadJumpLabelByKey, | |
| 2000 | + attachThreadListAutoAnimateRef, | |
| 2001 | + expandThreadListForProject, | |
| 2002 | + collapseThreadListForProject, | |
| 2003 | + dragInProgressRef, | |
| 2004 | + suppressProjectClickAfterDragRef, | |
| 2005 | + suppressProjectClickForContextMenuRef, | |
| 2006 | + attachProjectListAutoAnimateRef, | |
| 2007 | + projectsLength, | |
| 2008 | + } = props; | |
| 2009 | + | |
| 2010 | + const handleProjectSortOrderChange = useCallback( | |
| 2011 | + (sortOrder: SidebarProjectSortOrder) => { | |
| 2012 | + updateSettings({ sidebarProjectSortOrder: sortOrder }); | |
| 2013 | + }, | |
| 2014 | + [updateSettings], | |
| 2015 | + ); | |
| 2016 | + const handleThreadSortOrderChange = useCallback( | |
| 2017 | + (sortOrder: SidebarThreadSortOrder) => { | |
| 2018 | + updateSettings({ sidebarThreadSortOrder: sortOrder }); | |
| 2019 | + }, | |
| 2020 | + [updateSettings], | |
| 2021 | + ); | |
| 2022 | + const handleAddProjectInputChange = useCallback( | |
| 2023 | + (event: React.ChangeEvent<HTMLInputElement>) => { | |
| 2024 | + setNewCwd(event.target.value); | |
| 2025 | + setAddProjectError(null); | |
| 2026 | + }, | |
| 2027 | + [setAddProjectError, setNewCwd], | |
| 2028 | + ); | |
| 2029 | + const handleAddProjectInputKeyDown = useCallback( | |
| 2030 | + (event: React.KeyboardEvent<HTMLInputElement>) => { | |
| 2031 | + if (event.key === "Enter") handleAddProject(); | |
| 2032 | + if (event.key === "Escape") { | |
| 2033 | + setAddingProject(false); | |
| 2034 | + setAddProjectError(null); | |
| 1300 | 2035 | } |
| 1301 | − if (clicked === "copy-path") { | |
| 1302 | − copyPathToClipboard(project.cwd, { path: project.cwd }); | |
| 1303 | − return; | |
| 2036 | + }, | |
| 2037 | + [handleAddProject, setAddProjectError, setAddingProject], | |
| 2038 | + ); | |
| 2039 | + const handleBrowseForFolderClick = useCallback(() => { | |
| 2040 | + void handlePickFolder(); | |
| 2041 | + }, [handlePickFolder]); | |
| 2042 | + | |
| 2043 | + return ( | |
| 2044 | + <SidebarContent className="gap-0"> | |
| 2045 | + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( | |
| 2046 | + <SidebarGroup className="px-2 pt-2 pb-0"> | |
| 2047 | + <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8"> | |
| 2048 | + <TriangleAlertIcon /> | |
| 2049 | + <AlertTitle>Intel build on Apple Silicon</AlertTitle> | |
| 2050 | + <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription> | |
| 2051 | + {desktopUpdateButtonAction !== "none" ? ( | |
| 2052 | + <AlertAction> | |
| 2053 | + <Button | |
| 2054 | + size="xs" | |
| 2055 | + variant="outline" | |
| 2056 | + disabled={desktopUpdateButtonDisabled} | |
| 2057 | + onClick={handleDesktopUpdateButtonClick} | |
| 2058 | + > | |
| 2059 | + {desktopUpdateButtonAction === "download" | |
| 2060 | + ? "Download ARM build" | |
| 2061 | + : "Install ARM build"} | |
| 2062 | + </Button> | |
| 2063 | + </AlertAction> | |
| 2064 | + ) : null} | |
| 2065 | + </Alert> | |
| 2066 | + </SidebarGroup> | |
| 2067 | + ) : null} | |
| 2068 | + <SidebarGroup className="px-2 py-2"> | |
| 2069 | + <div className="mb-1 flex items-center justify-between pl-2 pr-1.5"> | |
| 2070 | + <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60"> | |
| 2071 | + Projects | |
| 2072 | + </span> | |
| 2073 | + <div className="flex items-center gap-1"> | |
| 2074 | + <ProjectSortMenu | |
| 2075 | + projectSortOrder={projectSortOrder} | |
| 2076 | + threadSortOrder={threadSortOrder} | |
| 2077 | + onProjectSortOrderChange={handleProjectSortOrderChange} | |
| 2078 | + onThreadSortOrderChange={handleThreadSortOrderChange} | |
| 2079 | + /> | |
| 2080 | + <Tooltip> | |
| 2081 | + <TooltipTrigger | |
| 2082 | + render={ | |
| 2083 | + <button | |
| 2084 | + type="button" | |
| 2085 | + aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} | |
| 2086 | + aria-pressed={shouldShowProjectPathEntry} | |
| 2087 | + className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" | |
| 2088 | + onClick={handleStartAddProject} | |
| 2089 | + /> | |
| 2090 | + } | |
| 2091 | + > | |
| 2092 | + <PlusIcon | |
| 2093 | + className={`size-3.5 transition-transform duration-150 ${ | |
| 2094 | + shouldShowProjectPathEntry ? "rotate-45" : "rotate-0" | |
| 2095 | + }`} | |
| 2096 | + /> | |
| 2097 | + </TooltipTrigger> | |
| 2098 | + <TooltipPopup side="right"> | |
| 2099 | + {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} | |
| 2100 | + </TooltipPopup> | |
| 2101 | + </Tooltip> | |
| 2102 | + </div> | |
| 2103 | + </div> | |
| 2104 | + {shouldShowProjectPathEntry && ( | |
| 2105 | + <div className="mb-2 px-1"> | |
| 2106 | + {isElectron && ( | |
| 2107 | + <button | |
| 2108 | + type="button" | |
| 2109 | + className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60" | |
| 2110 | + onClick={handleBrowseForFolderClick} | |
| 2111 | + disabled={isPickingFolder || isAddingProject} | |
| 2112 | + > | |
| 2113 | + <FolderIcon className="size-3.5" /> | |
| 2114 | + {isPickingFolder ? "Picking folder..." : "Browse for folder"} | |
| 2115 | + </button> | |
| 2116 | + )} | |
| 2117 | + <div className="flex gap-1.5"> | |
| 2118 | + <input | |
| 2119 | + ref={addProjectInputRef} | |
| 2120 | + className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${ | |
| 2121 | + addProjectError | |
| 2122 | + ? "border-red-500/70 focus:border-red-500" | |
| 2123 | + : "border-border focus:border-ring" | |
| 2124 | + }`} | |
| 2125 | + placeholder="/path/to/project" | |
| 2126 | + value={newCwd} | |
| 2127 | + onChange={handleAddProjectInputChange} | |
| 2128 | + onKeyDown={handleAddProjectInputKeyDown} | |
| 2129 | + autoFocus | |
| 2130 | + /> | |
| 2131 | + <button | |
| 2132 | + type="button" | |
| 2133 | + className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60" | |
| 2134 | + onClick={handleAddProject} | |
| 2135 | + disabled={!canAddProject} | |
| 2136 | + > | |
| 2137 | + {isAddingProject ? "Adding..." : "Add"} | |
| 2138 | + </button> | |
| 2139 | + </div> | |
| 2140 | + {addProjectError && ( | |
| 2141 | + <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400"> | |
| 2142 | + {addProjectError} | |
| 2143 | + </p> | |
| 2144 | + )} | |
| 2145 | + </div> | |
| 2146 | + )} | |
| 2147 | + | |
| 2148 | + {isManualProjectSorting ? ( | |
| 2149 | + <DndContext | |
| 2150 | + sensors={projectDnDSensors} | |
| 2151 | + collisionDetection={projectCollisionDetection} | |
| 2152 | + modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]} | |
| 2153 | + onDragStart={handleProjectDragStart} | |
| 2154 | + onDragEnd={handleProjectDragEnd} | |
| 2155 | + onDragCancel={handleProjectDragCancel} | |
| 2156 | + > | |
| 2157 | + <SidebarMenu> | |
| 2158 | + <SortableContext | |
| 2159 | + items={sortedProjects.map((project) => project.projectKey)} | |
| 2160 | + strategy={verticalListSortingStrategy} | |
| 2161 | + > | |
| 2162 | + {sortedProjects.map((project) => ( | |
| 2163 | + <SortableProjectItem key={project.projectKey} projectId={project.projectKey}> | |
| 2164 | + {(dragHandleProps) => ( | |
| 2165 | + <SidebarProjectItem | |
| 2166 | + project={project} | |
| 2167 | + isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)} | |
| 2168 | + activeRouteThreadKey={ | |
| 2169 | + activeRouteProjectKey === project.projectKey ? routeThreadKey : null | |
| 2170 | + } | |
| 2171 | + newThreadShortcutLabel={newThreadShortcutLabel} | |
| 2172 | + handleNewThread={handleNewThread} | |
| 2173 | + archiveThread={archiveThread} | |
| 2174 | + deleteThread={deleteThread} | |
| 2175 | + threadJumpLabelByKey={threadJumpLabelByKey} | |
| 2176 | + attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} | |
| 2177 | + expandThreadListForProject={expandThreadListForProject} | |
| 2178 | + collapseThreadListForProject={collapseThreadListForProject} | |
| 2179 | + dragInProgressRef={dragInProgressRef} | |
| 2180 | + suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef} | |
| 2181 | + suppressProjectClickForContextMenuRef={ | |
| 2182 | + suppressProjectClickForContextMenuRef | |
| 2183 | + } | |
| 2184 | + isManualProjectSorting={isManualProjectSorting} | |
| 2185 | + dragHandleProps={dragHandleProps} | |
| 2186 | + /> | |
| 2187 | + )} | |
| 2188 | + </SortableProjectItem> | |
| 2189 | + ))} | |
| 2190 | + </SortableContext> | |
| 2191 | + </SidebarMenu> | |
| 2192 | + </DndContext> | |
| 2193 | + ) : ( | |
| 2194 | + <SidebarMenu ref={attachProjectListAutoAnimateRef}> | |
| 2195 | + {sortedProjects.map((project) => ( | |
| 2196 | + <SidebarProjectListRow | |
| 2197 | + key={project.projectKey} | |
| 2198 | + project={project} | |
| 2199 | + isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)} | |
| 2200 | + activeRouteThreadKey={ | |
| 2201 | + activeRouteProjectKey === project.projectKey ? routeThreadKey : null | |
| 2202 | + } | |
| 2203 | + newThreadShortcutLabel={newThreadShortcutLabel} | |
| 2204 | + handleNewThread={handleNewThread} | |
| 2205 | + archiveThread={archiveThread} | |
| 2206 | + deleteThread={deleteThread} | |
| 2207 | + threadJumpLabelByKey={threadJumpLabelByKey} | |
| 2208 | + attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} | |
| 2209 | + expandThreadListForProject={expandThreadListForProject} | |
| 2210 | + collapseThreadListForProject={collapseThreadListForProject} | |
| 2211 | + dragInProgressRef={dragInProgressRef} | |
| 2212 | + suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef} | |
| 2213 | + suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} | |
| 2214 | + isManualProjectSorting={isManualProjectSorting} | |
| 2215 | + dragHandleProps={null} | |
| 2216 | + /> | |
| 2217 | + ))} | |
| 2218 | + </SidebarMenu> | |
| 2219 | + )} | |
| 2220 | + | |
| 2221 | + {projectsLength === 0 && !shouldShowProjectPathEntry && ( | |
| 2222 | + <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | |
| 2223 | + No projects yet | |
| 2224 | + </div> | |
| 2225 | + )} | |
| 2226 | + </SidebarGroup> | |
| 2227 | + </SidebarContent> | |
| 2228 | + ); | |
| 2229 | +}); | |
| 2230 | + | |
| 2231 | +export default function Sidebar() { | |
| 2232 | + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); | |
| 2233 | + const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); | |
| 2234 | + const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); | |
| 2235 | + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); | |
| 2236 | + const projectOrder = useUiStateStore((store) => store.projectOrder); | |
| 2237 | + const reorderProjects = useUiStateStore((store) => store.reorderProjects); | |
| 2238 | + const navigate = useNavigate(); | |
| 2239 | + const pathname = useLocation({ select: (loc) => loc.pathname }); | |
| 2240 | + const isOnSettings = pathname.startsWith("/settings"); | |
| 2241 | + const appSettings = useSettings(); | |
| 2242 | + const { updateSettings } = useUpdateSettings(); | |
| 2243 | + const { handleNewThread } = useHandleNewThread(); | |
| 2244 | + const { archiveThread, deleteThread } = useThreadActions(); | |
| 2245 | + const routeThreadRef = useParams({ | |
| 2246 | + strict: false, | |
| 2247 | + select: (params) => resolveThreadRouteRef(params), | |
| 2248 | + }); | |
| 2249 | + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; | |
| 2250 | + const keybindings = useServerKeybindings(); | |
| 2251 | + const [addingProject, setAddingProject] = useState(false); | |
| 2252 | + const [newCwd, setNewCwd] = useState(""); | |
| 2253 | + const [isPickingFolder, setIsPickingFolder] = useState(false); | |
| 2254 | + const [isAddingProject, setIsAddingProject] = useState(false); | |
| 2255 | + const [addProjectError, setAddProjectError] = useState<string | null>(null); | |
| 2256 | + const addProjectInputRef = useRef<HTMLInputElement | null>(null); | |
| 2257 | + const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< | |
| 2258 | + ReadonlySet<string> | |
| 2259 | + >(() => new Set()); | |
| 2260 | + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); | |
| 2261 | + const dragInProgressRef = useRef(false); | |
| 2262 | + const suppressProjectClickAfterDragRef = useRef(false); | |
| 2263 | + const suppressProjectClickForContextMenuRef = useRef(false); | |
| 2264 | + const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null); | |
| 2265 | + const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); | |
| 2266 | + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); | |
| 2267 | + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); | |
| 2268 | + const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); | |
| 2269 | + const platform = navigator.platform; | |
| 2270 | + const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; | |
| 2271 | + const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; | |
| 2272 | + const orderedProjects = useMemo(() => { | |
| 2273 | + return orderItemsByPreferredIds({ | |
| 2274 | + items: projects, | |
| 2275 | + preferredIds: projectOrder, | |
| 2276 | + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 2277 | + }); | |
| 2278 | + }, [projectOrder, projects]); | |
| 2279 | + const sidebarProjects = useMemo<SidebarProjectSnapshot[]>( | |
| 2280 | + () => | |
| 2281 | + orderedProjects.map((project) => ({ | |
| 2282 | + ...project, | |
| 2283 | + projectKey: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 2284 | + })), | |
| 2285 | + [orderedProjects], | |
| 2286 | + ); | |
| 2287 | + const sidebarProjectByKey = useMemo( | |
| 2288 | + () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), | |
| 2289 | + [sidebarProjects], | |
| 2290 | + ); | |
| 2291 | + const sidebarThreadByKey = useMemo( | |
| 2292 | + () => | |
| 2293 | + new Map( | |
| 2294 | + sidebarThreads.map( | |
| 2295 | + (thread) => | |
| 2296 | + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, | |
| 2297 | + ), | |
| 2298 | + ), | |
| 2299 | + [sidebarThreads], | |
| 2300 | + ); | |
| 2301 | + const activeRouteProjectKey = useMemo(() => { | |
| 2302 | + if (!routeThreadKey) { | |
| 2303 | + return null; | |
| 2304 | + } | |
| 2305 | + const activeThread = sidebarThreadByKey.get(routeThreadKey); | |
| 2306 | + return activeThread | |
| 2307 | + ? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)) | |
| 2308 | + : null; | |
| 2309 | + }, [routeThreadKey, sidebarThreadByKey]); | |
| 2310 | + const threadsByProjectKey = useMemo(() => { | |
| 2311 | + const next = new Map<string, SidebarThreadSummary[]>(); | |
| 2312 | + for (const thread of sidebarThreads) { | |
| 2313 | + const projectKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); | |
| 2314 | + const existing = next.get(projectKey); | |
| 2315 | + if (existing) { | |
| 2316 | + existing.push(thread); | |
| 2317 | + } else { | |
| 2318 | + next.set(projectKey, [thread]); | |
| 1304 | 2319 | } |
| 1305 | − if (clicked !== "delete") return; | |
| 2320 | + } | |
| 2321 | + return next; | |
| 2322 | + }, [sidebarThreads]); | |
| 2323 | + const getCurrentSidebarShortcutContext = useCallback( | |
| 2324 | + () => ({ | |
| 2325 | + terminalFocus: isTerminalFocused(), | |
| 2326 | + terminalOpen: routeThreadRef | |
| 2327 | + ? selectThreadTerminalState( | |
| 2328 | + useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 2329 | + routeThreadRef, | |
| 2330 | + ).terminalOpen | |
| 2331 | + : false, | |
| 2332 | + }), | |
| 2333 | + [routeThreadRef], | |
| 2334 | + ); | |
| 2335 | + const newThreadShortcutLabelOptions = useMemo( | |
| 2336 | + () => ({ | |
| 2337 | + platform, | |
| 2338 | + context: { | |
| 2339 | + terminalFocus: false, | |
| 2340 | + terminalOpen: false, | |
| 2341 | + }, | |
| 2342 | + }), | |
| 2343 | + [platform], | |
| 2344 | + ); | |
| 2345 | + const newThreadShortcutLabel = | |
| 2346 | + shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? | |
| 2347 | + shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); | |
| 2348 | + const focusMostRecentThreadForProject = useCallback( | |
| 2349 | + (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { | |
| 2350 | + const projectKey = scopedProjectKey( | |
| 2351 | + scopeProjectRef(projectRef.environmentId, projectRef.projectId), | |
| 2352 | + ); | |
| 2353 | + const latestThread = sortThreadsForSidebar( | |
| 2354 | + (threadsByProjectKey.get(projectKey) ?? []).filter((thread) => thread.archivedAt === null), | |
| 2355 | + appSettings.sidebarThreadSortOrder, | |
| 2356 | + )[0]; | |
| 2357 | + if (!latestThread) return; | |
| 1306 | 2358 | |
| 1307 | − const projectThreadIds = threadIdsByProjectId[projectId] ?? []; | |
| 1308 | − if (projectThreadIds.length > 0) { | |
| 1309 | − toastManager.add({ | |
| 1310 | − type: "warning", | |
| 1311 | − title: "Project is not empty", | |
| 1312 | − description: "Delete all threads in this project before removing it.", | |
| 2359 | + void navigate({ | |
| 2360 | + to: "/$environmentId/$threadId", | |
| 2361 | + params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), | |
| 2362 | + }); | |
| 2363 | + }, | |
| 2364 | + [appSettings.sidebarThreadSortOrder, navigate, threadsByProjectKey], | |
| 2365 | + ); | |
| 2366 | + | |
| 2367 | + const addProjectFromPath = useCallback( | |
| 2368 | + async (rawCwd: string) => { | |
| 2369 | + const cwd = rawCwd.trim(); | |
| 2370 | + if (!cwd || isAddingProject) return; | |
| 2371 | + const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; | |
| 2372 | + if (!api) return; | |
| 2373 | + | |
| 2374 | + setIsAddingProject(true); | |
| 2375 | + const finishAddingProject = () => { | |
| 2376 | + setIsAddingProject(false); | |
| 2377 | + setNewCwd(""); | |
| 2378 | + setAddProjectError(null); | |
| 2379 | + setAddingProject(false); | |
| 2380 | + }; | |
| 2381 | + | |
| 2382 | + const existing = projects.find((project) => project.cwd === cwd); | |
| 2383 | + if (existing) { | |
| 2384 | + focusMostRecentThreadForProject({ | |
| 2385 | + environmentId: existing.environmentId, | |
| 2386 | + projectId: existing.id, | |
| 1313 | 2387 | }); |
| 2388 | + finishAddingProject(); | |
| 1314 | 2389 | return; |
| 1315 | 2390 | } |
| 1316 | 2391 | |
| 1317 | − const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); | |
| 1318 | − if (!confirmed) return; | |
| 1319 | − | |
| 2392 | + const projectId = newProjectId(); | |
| 2393 | + const createdAt = new Date().toISOString(); | |
| 2394 | + const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; | |
| 1320 | 2395 | try { |
| 1321 | − const projectDraftThread = getDraftThreadByProjectId(projectId); | |
| 1322 | − if (projectDraftThread) { | |
| 1323 | − clearComposerDraftForThread(projectDraftThread.threadId); | |
| 1324 | − } | |
| 1325 | − clearProjectDraftThreadId(projectId); | |
| 1326 | 2396 | await api.orchestration.dispatchCommand({ |
| 1327 | − type: "project.delete", | |
| 2397 | + type: "project.create", | |
| 1328 | 2398 | commandId: newCommandId(), |
| 1329 | 2399 | projectId, |
| 2400 | + title, | |
| 2401 | + workspaceRoot: cwd, | |
| 2402 | + defaultModelSelection: { | |
| 2403 | + provider: "codex", | |
| 2404 | + model: DEFAULT_MODEL_BY_PROVIDER.codex, | |
| 2405 | + }, | |
| 2406 | + createdAt, | |
| 1330 | 2407 | }); |
| 2408 | + if (activeEnvironmentId !== null) { | |
| 2409 | + await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { | |
| 2410 | + envMode: appSettings.defaultThreadEnvMode, | |
| 2411 | + }).catch(() => undefined); | |
| 2412 | + } | |
| 1331 | 2413 | } catch (error) { |
| 1332 | − const message = error instanceof Error ? error.message : "Unknown error removing project."; | |
| 1333 | − console.error("Failed to remove project", { projectId, error }); | |
| 1334 | − toastManager.add({ | |
| 1335 | − type: "error", | |
| 1336 | − title: `Failed to remove "${project.name}"`, | |
| 1337 | − description: message, | |
| 1338 | − }); | |
| 2414 | + const description = | |
| 2415 | + error instanceof Error ? error.message : "An error occurred while adding the project."; | |
| 2416 | + setIsAddingProject(false); | |
| 2417 | + if (shouldBrowseForProjectImmediately) { | |
| 2418 | + toastManager.add({ | |
| 2419 | + type: "error", | |
| 2420 | + title: "Failed to add project", | |
| 2421 | + description, | |
| 2422 | + }); | |
| 2423 | + } else { | |
| 2424 | + setAddProjectError(description); | |
| 2425 | + } | |
| 2426 | + return; | |
| 1339 | 2427 | } |
| 2428 | + finishAddingProject(); | |
| 1340 | 2429 | }, |
| 1341 | 2430 | [ |
| 1342 | − clearComposerDraftForThread, | |
| 1343 | − clearProjectDraftThreadId, | |
| 1344 | − copyPathToClipboard, | |
| 1345 | − getDraftThreadByProjectId, | |
| 2431 | + focusMostRecentThreadForProject, | |
| 2432 | + activeEnvironmentId, | |
| 2433 | + handleNewThread, | |
| 2434 | + isAddingProject, | |
| 1346 | 2435 | projects, |
| 1347 | − threadIdsByProjectId, | |
| 2436 | + shouldBrowseForProjectImmediately, | |
| 2437 | + appSettings.defaultThreadEnvMode, | |
| 1348 | 2438 | ], |
| 1349 | 2439 | ); |
| 1350 | 2440 | |
| 2441 | + const handleAddProject = () => { | |
| 2442 | + void addProjectFromPath(newCwd); | |
| 2443 | + }; | |
| 2444 | + | |
| 2445 | + const canAddProject = newCwd.trim().length > 0 && !isAddingProject; | |
| 2446 | + | |
| 2447 | + const handlePickFolder = async () => { | |
| 2448 | + const api = readLocalApi(); | |
| 2449 | + if (!api || isPickingFolder) return; | |
| 2450 | + setIsPickingFolder(true); | |
| 2451 | + let pickedPath: string | null = null; | |
| 2452 | + try { | |
| 2453 | + pickedPath = await api.dialogs.pickFolder(); | |
| 2454 | + } catch { | |
| 2455 | + // Ignore picker failures and leave the current thread selection unchanged. | |
| 2456 | + } | |
| 2457 | + if (pickedPath) { | |
| 2458 | + await addProjectFromPath(pickedPath); | |
| 2459 | + } else if (!shouldBrowseForProjectImmediately) { | |
| 2460 | + addProjectInputRef.current?.focus(); | |
| 2461 | + } | |
| 2462 | + setIsPickingFolder(false); | |
| 2463 | + }; | |
| 2464 | + | |
| 2465 | + const handleStartAddProject = () => { | |
| 2466 | + setAddProjectError(null); | |
| 2467 | + if (shouldBrowseForProjectImmediately) { | |
| 2468 | + void handlePickFolder(); | |
| 2469 | + return; | |
| 2470 | + } | |
| 2471 | + setAddingProject((prev) => !prev); | |
| 2472 | + }; | |
| 2473 | + | |
| 2474 | + const navigateToThread = useCallback( | |
| 2475 | + (threadRef: ScopedThreadRef) => { | |
| 2476 | + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { | |
| 2477 | + clearSelection(); | |
| 2478 | + } | |
| 2479 | + setSelectionAnchor(scopedThreadKey(threadRef)); | |
| 2480 | + void navigate({ | |
| 2481 | + to: "/$environmentId/$threadId", | |
| 2482 | + params: buildThreadRouteParams(threadRef), | |
| 2483 | + }); | |
| 2484 | + }, | |
| 2485 | + [clearSelection, navigate, setSelectionAnchor], | |
| 2486 | + ); | |
| 2487 | + | |
| 1351 | 2488 | const projectDnDSensors = useSensors( |
| 1352 | 2489 | useSensor(PointerSensor, { |
| 1353 | 2490 | activationConstraint: { distance: 6 }, |
| @@ -1371,10 +2508,10 @@ export default function Sidebar() { · 17 unmodified lines | ||
| 1371 | 2508 | dragInProgressRef.current = false; |
| 1372 | 2509 | const { active, over } = event; |
| 1373 | 2510 | if (!over || active.id === over.id) return; |
| 1374 | − const activeProject = sidebarProjects.find((project) => project.id === active.id); | |
| 1375 | − const overProject = sidebarProjects.find((project) => project.id === over.id); | |
| 2511 | + const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); | |
| 2512 | + const overProject = sidebarProjects.find((project) => project.projectKey === over.id); | |
| 1376 | 2513 | if (!activeProject || !overProject) return; |
| 1377 | − reorderProjects(activeProject.id, overProject.id); | |
| 2514 | + reorderProjects(activeProject.projectKey, overProject.projectKey); | |
| 1378 | 2515 | }, |
| 1379 | 2516 | [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], |
| 1380 | 2517 | ); |
| @@ -1412,148 +2549,117 @@ export default function Sidebar() { · 31 unmodified lines | ||
| 1412 | 2549 | animatedThreadListsRef.current.add(node); |
| 1413 | 2550 | }, []); |
| 1414 | 2551 | |
| 1415 | − const handleProjectTitlePointerDownCapture = useCallback( | |
| 1416 | − (event: PointerEvent<HTMLButtonElement>) => { | |
| 1417 | − suppressProjectClickForContextMenuRef.current = false; | |
| 1418 | − if ( | |
| 1419 | − isContextMenuPointerDown({ | |
| 1420 | − button: event.button, | |
| 1421 | − ctrlKey: event.ctrlKey, | |
| 1422 | − isMac: isMacPlatform(navigator.platform), | |
| 1423 | − }) | |
| 1424 | − ) { | |
| 1425 | − // Keep context-menu gestures from arming the sortable drag sensor. | |
| 1426 | − event.stopPropagation(); | |
| 1427 | − } | |
| 1428 | − | |
| 1429 | − suppressProjectClickAfterDragRef.current = false; | |
| 1430 | − }, | |
| 1431 | − [], | |
| 1432 | − ); | |
| 1433 | − | |
| 1434 | 2552 | const visibleThreads = useMemo( |
| 1435 | 2553 | () => sidebarThreads.filter((thread) => thread.archivedAt === null), |
| 1436 | 2554 | [sidebarThreads], |
| 1437 | 2555 | ); |
| 1438 | − const sortedProjects = useMemo( | |
| 1439 | − () => | |
| 1440 | − sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder), | |
| 1441 | − [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads], | |
| 1442 | − ); | |
| 2556 | + const sortedProjects = useMemo(() => { | |
| 2557 | + const sortableProjects = sidebarProjects.map((project) => ({ | |
| 2558 | + ...project, | |
| 2559 | + id: project.projectKey, | |
| 2560 | + })); | |
| 2561 | + const sortableThreads = visibleThreads.map((thread) => ({ | |
| 2562 | + ...thread, | |
| 2563 | + projectId: scopedProjectKey( | |
| 2564 | + scopeProjectRef(thread.environmentId, thread.projectId), | |
| 2565 | + ) as ProjectId, | |
| 2566 | + })); | |
| 2567 | + return sortProjectsForSidebar( | |
| 2568 | + sortableProjects, | |
| 2569 | + sortableThreads, | |
| 2570 | + appSettings.sidebarProjectSortOrder, | |
| 2571 | + ).flatMap((project) => { | |
| 2572 | + const resolvedProject = sidebarProjectByKey.get(project.id); | |
| 2573 | + return resolvedProject ? [resolvedProject] : []; | |
| 2574 | + }); | |
| 2575 | + }, [appSettings.sidebarProjectSortOrder, sidebarProjectByKey, sidebarProjects, visibleThreads]); | |
| 1443 | 2576 | const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; |
| 1444 | − const renderedProjects = useMemo( | |
| 2577 | + const visibleSidebarThreadKeys = useMemo( | |
| 1445 | 2578 | () => |
| 1446 | − sortedProjects.map((project) => { | |
| 1447 | − const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) => | |
| 1448 | − resolveThreadStatusPill({ | |
| 1449 | − thread: { | |
| 1450 | − ...thread, | |
| 1451 | − lastVisitedAt: threadLastVisitedAtById[thread.id], | |
| 1452 | − }, | |
| 1453 | − }); | |
| 2579 | + sortedProjects.flatMap((project) => { | |
| 1454 | 2580 | const projectThreads = sortThreadsForSidebar( |
| 1455 | − (threadIdsByProjectId[project.id] ?? []) | |
| 1456 | − .map((threadId) => sidebarThreadsById[threadId]) | |
| 1457 | − .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined) | |
| 1458 | − .filter((thread) => thread.archivedAt === null), | |
| 2581 | + (threadsByProjectKey.get(project.projectKey) ?? []).filter( | |
| 2582 | + (thread) => thread.archivedAt === null, | |
| 2583 | + ), | |
| 1459 | 2584 | appSettings.sidebarThreadSortOrder, |
| 1460 | 2585 | ); |
| 1461 | − const projectStatus = resolveProjectStatusIndicator( | |
| 1462 | − projectThreads.map((thread) => resolveProjectThreadStatus(thread)), | |
| 1463 | − ); | |
| 1464 | − const activeThreadId = routeThreadId ?? undefined; | |
| 1465 | − const isThreadListExpanded = expandedThreadListsByProject.has(project.id); | |
| 2586 | + const projectExpanded = projectExpandedById[project.projectKey] ?? true; | |
| 2587 | + const activeThreadKey = routeThreadKey ?? undefined; | |
| 1466 | 2588 | const pinnedCollapsedThread = |
| 1467 | − !project.expanded && activeThreadId | |
| 1468 | − ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) | |
| 2589 | + !projectExpanded && activeThreadKey | |
| 2590 | + ? (projectThreads.find( | |
| 2591 | + (thread) => | |
| 2592 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === | |
| 2593 | + activeThreadKey, | |
| 2594 | + ) ?? null) | |
| 1469 | 2595 | : null; |
| 1470 | − const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; | |
| 1471 | − const { | |
| 1472 | − hasHiddenThreads, | |
| 1473 | − hiddenThreads, | |
| 1474 | − visibleThreads: visibleProjectThreads, | |
| 1475 | − } = getVisibleThreadsForProject({ | |
| 1476 | − threads: projectThreads, | |
| 1477 | − activeThreadId, | |
| 1478 | − isThreadListExpanded, | |
| 1479 | − previewLimit: THREAD_PREVIEW_LIMIT, | |
| 1480 | − }); | |
| 1481 | − const hiddenThreadStatus = resolveProjectStatusIndicator( | |
| 1482 | − hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), | |
| 2596 | + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; | |
| 2597 | + if (!shouldShowThreadPanel) { | |
| 2598 | + return []; | |
| 2599 | + } | |
| 2600 | + const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); | |
| 2601 | + const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; | |
| 2602 | + const previewThreads = | |
| 2603 | + isThreadListExpanded || !hasOverflowingThreads | |
| 2604 | + ? projectThreads | |
| 2605 | + : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); | |
| 2606 | + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; | |
| 2607 | + return renderedThreads.map((thread) => | |
| 2608 | + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 1483 | 2609 | ); |
| 1484 | − const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); | |
| 1485 | − const renderedThreadIds = pinnedCollapsedThread | |
| 1486 | − ? [pinnedCollapsedThread.id] | |
| 1487 | − : visibleProjectThreads.map((thread) => thread.id); | |
| 1488 | − const showEmptyThreadState = project.expanded && projectThreads.length === 0; | |
| 1489 | − | |
| 1490 | − return { | |
| 1491 | − hasHiddenThreads, | |
| 1492 | − hiddenThreadStatus, | |
| 1493 | − orderedProjectThreadIds, | |
| 1494 | − project, | |
| 1495 | − projectStatus, | |
| 1496 | − renderedThreadIds, | |
| 1497 | − showEmptyThreadState, | |
| 1498 | − shouldShowThreadPanel, | |
| 1499 | − isThreadListExpanded, | |
| 1500 | − }; | |
| 1501 | 2610 | }), |
| 1502 | 2611 | [ |
| 1503 | 2612 | appSettings.sidebarThreadSortOrder, |
| 1504 | 2613 | expandedThreadListsByProject, |
| 1505 | − routeThreadId, | |
| 2614 | + projectExpandedById, | |
| 2615 | + routeThreadKey, | |
| 1506 | 2616 | sortedProjects, |
| 1507 | − sidebarThreadsById, | |
| 1508 | − threadIdsByProjectId, | |
| 1509 | − threadLastVisitedAtById, | |
| 2617 | + threadsByProjectKey, | |
| 1510 | 2618 | ], |
| 1511 | 2619 | ); |
| 1512 | − const visibleSidebarThreadIds = useMemo( | |
| 1513 | − () => getVisibleSidebarThreadIds(renderedProjects), | |
| 1514 | − [renderedProjects], | |
| 1515 | − ); | |
| 1516 | − const threadJumpCommandById = useMemo(() => { | |
| 1517 | − const mapping = new Map<ThreadId, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>(); | |
| 1518 | − for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) { | |
| 2620 | + const threadJumpCommandByKey = useMemo(() => { | |
| 2621 | + const mapping = new Map<string, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>(); | |
| 2622 | + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { | |
| 1519 | 2623 | const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); |
| 1520 | 2624 | if (!jumpCommand) { |
| 1521 | 2625 | return mapping; |
| 1522 | 2626 | } |
| 1523 | − mapping.set(threadId, jumpCommand); | |
| 2627 | + mapping.set(threadKey, jumpCommand); | |
| 1524 | 2628 | } |
| 1525 | 2629 | |
| 1526 | 2630 | return mapping; |
| 1527 | − }, [visibleSidebarThreadIds]); | |
| 1528 | − const threadJumpThreadIds = useMemo( | |
| 1529 | − () => [...threadJumpCommandById.keys()], | |
| 1530 | − [threadJumpCommandById], | |
| 1531 | − ); | |
| 1532 | − const threadJumpLabelById = useMemo(() => { | |
| 1533 | − const mapping = new Map<ThreadId, string>(); | |
| 1534 | − for (const [threadId, command] of threadJumpCommandById) { | |
| 1535 | − const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions); | |
| 1536 | − if (label) { | |
| 1537 | − mapping.set(threadId, label); | |
| 1538 | − } | |
| 1539 | − } | |
| 1540 | − return mapping; | |
| 1541 | − }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); | |
| 1542 | − const orderedSidebarThreadIds = visibleSidebarThreadIds; | |
| 2631 | + }, [visibleSidebarThreadKeys]); | |
| 2632 | + const threadJumpThreadKeys = useMemo( | |
| 2633 | + () => [...threadJumpCommandByKey.keys()], | |
| 2634 | + [threadJumpCommandByKey], | |
| 2635 | + ); | |
| 2636 | + const [threadJumpLabelByKey, setThreadJumpLabelByKey] = | |
| 2637 | + useState<ReadonlyMap<string, string>>(EMPTY_THREAD_JUMP_LABELS); | |
| 2638 | + const visibleThreadJumpLabelByKey = showThreadJumpHints | |
| 2639 | + ? threadJumpLabelByKey | |
| 2640 | + : EMPTY_THREAD_JUMP_LABELS; | |
| 2641 | + const orderedSidebarThreadKeys = visibleSidebarThreadKeys; | |
| 1543 | 2642 | |
| 1544 | 2643 | useEffect(() => { |
| 1545 | − const getShortcutContext = () => ({ | |
| 1546 | − terminalFocus: isTerminalFocused(), | |
| 1547 | − terminalOpen: routeTerminalOpen, | |
| 1548 | − }); | |
| 1549 | − | |
| 1550 | 2644 | const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { |
| 1551 | − updateThreadJumpHintsVisibility( | |
| 1552 | − shouldShowThreadJumpHints(event, keybindings, { | |
| 2645 | + const shortcutContext = getCurrentSidebarShortcutContext(); | |
| 2646 | + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { | |
| 2647 | + platform, | |
| 2648 | + context: shortcutContext, | |
| 2649 | + }); | |
| 2650 | + setThreadJumpLabelByKey((current) => { | |
| 2651 | + if (!shouldShowHints) { | |
| 2652 | + return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; | |
| 2653 | + } | |
| 2654 | + const nextLabelMap = buildThreadJumpLabelMap({ | |
| 2655 | + keybindings, | |
| 1553 | 2656 | platform, |
| 1554 | − context: getShortcutContext(), | |
| 1555 | − }), | |
| 1556 | − ); | |
| 2657 | + terminalOpen: shortcutContext.terminalOpen, | |
| 2658 | + threadJumpCommandByKey, | |
| 2659 | + }); | |
| 2660 | + return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; | |
| 2661 | + }); | |
| 2662 | + updateThreadJumpHintsVisibility(shouldShowHints); | |
| 1557 | 2663 | |
| 1558 | 2664 | if (event.defaultPrevented || event.repeat) { |
| 1559 | 2665 | return; |
| @@ -1561,22 +2667,26 @@ export default function Sidebar() { · 1 unmodified line | ||
| 1561 | 2667 | |
| 1562 | 2668 | const command = resolveShortcutCommand(event, keybindings, { |
| 1563 | 2669 | platform, |
| 1564 | − context: getShortcutContext(), | |
| 2670 | + context: shortcutContext, | |
| 1565 | 2671 | }); |
| 1566 | 2672 | const traversalDirection = threadTraversalDirectionFromCommand(command); |
| 1567 | 2673 | if (traversalDirection !== null) { |
| 1568 | − const targetThreadId = resolveAdjacentThreadId({ | |
| 1569 | − threadIds: orderedSidebarThreadIds, | |
| 1570 | − currentThreadId: routeThreadId, | |
| 2674 | + const targetThreadKey = resolveAdjacentThreadId({ | |
| 2675 | + threadIds: orderedSidebarThreadKeys, | |
| 2676 | + currentThreadId: routeThreadKey, | |
| 1571 | 2677 | direction: traversalDirection, |
| 1572 | 2678 | }); |
| 1573 | − if (!targetThreadId) { | |
| 2679 | + if (!targetThreadKey) { | |
| 2680 | + return; | |
| 2681 | + } | |
| 2682 | + const targetThread = sidebarThreadByKey.get(targetThreadKey); | |
| 2683 | + if (!targetThread) { | |
| 1574 | 2684 | return; |
| 1575 | 2685 | } |
| 1576 | 2686 | |
| 1577 | 2687 | event.preventDefault(); |
| 1578 | 2688 | event.stopPropagation(); |
| 1579 | − navigateToThread(targetThreadId); | |
| 2689 | + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); | |
| 1580 | 2690 | return; |
| 1581 | 2691 | } |
| 1582 | 2692 | |
| @@ -1585,26 +2695,45 @@ export default function Sidebar() { · 2 unmodified lines | ||
| 1585 | 2695 | return; |
| 1586 | 2696 | } |
| 1587 | 2697 | |
| 1588 | − const targetThreadId = threadJumpThreadIds[jumpIndex]; | |
| 1589 | − if (!targetThreadId) { | |
| 2698 | + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; | |
| 2699 | + if (!targetThreadKey) { | |
| 2700 | + return; | |
| 2701 | + } | |
| 2702 | + const targetThread = sidebarThreadByKey.get(targetThreadKey); | |
| 2703 | + if (!targetThread) { | |
| 1590 | 2704 | return; |
| 1591 | 2705 | } |
| 1592 | 2706 | |
| 1593 | 2707 | event.preventDefault(); |
| 1594 | 2708 | event.stopPropagation(); |
| 1595 | − navigateToThread(targetThreadId); | |
| 2709 | + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); | |
| 1596 | 2710 | }; |
| 1597 | 2711 | |
| 1598 | 2712 | const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { |
| 1599 | − updateThreadJumpHintsVisibility( | |
| 1600 | − shouldShowThreadJumpHints(event, keybindings, { | |
| 2713 | + const shortcutContext = getCurrentSidebarShortcutContext(); | |
| 2714 | + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { | |
| 2715 | + platform, | |
| 2716 | + context: shortcutContext, | |
| 2717 | + }); | |
| 2718 | + setThreadJumpLabelByKey((current) => { | |
| 2719 | + if (!shouldShowHints) { | |
| 2720 | + return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; | |
| 2721 | + } | |
| 2722 | + const nextLabelMap = buildThreadJumpLabelMap({ | |
| 2723 | + keybindings, | |
| 1601 | 2724 | platform, |
| 1602 | − context: getShortcutContext(), | |
| 1603 | − }), | |
| 1604 | − ); | |
| 2725 | + terminalOpen: shortcutContext.terminalOpen, | |
| 2726 | + threadJumpCommandByKey, | |
| 2727 | + }); | |
| 2728 | + return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; | |
| 2729 | + }); | |
| 2730 | + updateThreadJumpHintsVisibility(shouldShowHints); | |
| 1605 | 2731 | }; |
| 1606 | 2732 | |
| 1607 | 2733 | const onWindowBlur = () => { |
| 2734 | + setThreadJumpLabelByKey((current) => | |
| 2735 | + current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, | |
| 2736 | + ); | |
| 1608 | 2737 | updateThreadJumpHintsVisibility(false); |
| 1609 | 2738 | }; |
| 1610 | 2739 | |
| @@ -1618,301 +2747,21 @@ export default function Sidebar() { · 7 unmodified lines | ||
| 1618 | 2747 | window.removeEventListener("blur", onWindowBlur); |
| 1619 | 2748 | }; |
| 1620 | 2749 | }, [ |
| 2750 | + getCurrentSidebarShortcutContext, | |
| 1621 | 2751 | keybindings, |
| 1622 | 2752 | navigateToThread, |
| 1623 | − orderedSidebarThreadIds, | |
| 2753 | + orderedSidebarThreadKeys, | |
| 1624 | 2754 | platform, |
| 1625 | − routeTerminalOpen, | |
| 1626 | − routeThreadId, | |
| 1627 | − threadJumpThreadIds, | |
| 2755 | + routeThreadKey, | |
| 2756 | + sidebarThreadByKey, | |
| 2757 | + threadJumpCommandByKey, | |
| 2758 | + threadJumpThreadKeys, | |
| 1628 | 2759 | updateThreadJumpHintsVisibility, |
| 1629 | 2760 | ]); |
| 1630 | 2761 | |
| 1631 | − function renderProjectItem( | |
| 1632 | − renderedProject: (typeof renderedProjects)[number], | |
| 1633 | − dragHandleProps: SortableProjectHandleProps | null, | |
| 1634 | − ) { | |
| 1635 | − const { | |
| 1636 | − hasHiddenThreads, | |
| 1637 | − hiddenThreadStatus, | |
| 1638 | − orderedProjectThreadIds, | |
| 1639 | − project, | |
| 1640 | − projectStatus, | |
| 1641 | − renderedThreadIds, | |
| 1642 | − showEmptyThreadState, | |
| 1643 | − shouldShowThreadPanel, | |
| 1644 | − isThreadListExpanded, | |
| 1645 | − } = renderedProject; | |
| 1646 | − return ( | |
| 1647 | − <> | |
| 1648 | − <div className="group/project-header relative"> | |
| 1649 | − <SidebarMenuButton | |
| 1650 | − ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined} | |
| 1651 | − size="sm" | |
| 1652 | − className={`gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground ${ | |
| 1653 | − isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer" | |
| 1654 | − }`} | |
| 1655 | − {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})} | |
| 1656 | − {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})} | |
| 1657 | − onPointerDownCapture={handleProjectTitlePointerDownCapture} | |
| 1658 | − onClick={(event) => handleProjectTitleClick(event, project.id)} | |
| 1659 | − onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} | |
| 1660 | − onContextMenu={(event) => { | |
| 1661 | − event.preventDefault(); | |
| 1662 | − suppressProjectClickForContextMenuRef.current = true; | |
| 1663 | − void handleProjectContextMenu(project.id, { | |
| 1664 | − x: event.clientX, | |
| 1665 | − y: event.clientY, | |
| 1666 | − }); | |
| 1667 | − }} | |
| 1668 | − > | |
| 1669 | − {!project.expanded && projectStatus ? ( | |
| 1670 | − <span | |
| 1671 | − aria-hidden="true" | |
| 1672 | − title={projectStatus.label} | |
| 1673 | − className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`} | |
| 1674 | − > | |
| 1675 | − <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0"> | |
| 1676 | − <span | |
| 1677 | − className={`size-[9px] rounded-full ${projectStatus.dotClass} ${ | |
| 1678 | − projectStatus.pulse ? "animate-pulse" : "" | |
| 1679 | − }`} | |
| 1680 | − /> | |
| 1681 | − </span> | |
| 1682 | − <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" /> | |
| 1683 | − </span> | |
| 1684 | − ) : ( | |
| 1685 | − <ChevronRightIcon | |
| 1686 | − className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${ | |
| 1687 | − project.expanded ? "rotate-90" : "" | |
| 1688 | − }`} | |
| 1689 | − /> | |
| 1690 | − )} | |
| 1691 | − <ProjectFavicon cwd={project.cwd} /> | |
| 1692 | − {renamingProjectId === project.id ? ( | |
| 1693 | − <input | |
| 1694 | − ref={(element) => { | |
| 1695 | − if (element && projectRenamingInputRef.current !== element) { | |
| 1696 | − projectRenamingInputRef.current = element; | |
| 1697 | − element.focus(); | |
| 1698 | − element.select(); | |
| 1699 | − } | |
| 1700 | − }} | |
| 1701 | − className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-xs font-medium text-foreground/90 outline-none" | |
| 1702 | − value={renamingProjectTitle} | |
| 1703 | − onChange={(event) => setRenamingProjectTitle(event.target.value)} | |
| 1704 | − onKeyDown={(event) => { | |
| 1705 | − event.stopPropagation(); | |
| 1706 | − if (event.key === "Enter") { | |
| 1707 | − event.preventDefault(); | |
| 1708 | − projectRenamingCommittedRef.current = true; | |
| 1709 | − void commitProjectRename(project.id, renamingProjectTitle, project.name); | |
| 1710 | − } else if (event.key === "Escape") { | |
| 1711 | − event.preventDefault(); | |
| 1712 | − projectRenamingCommittedRef.current = true; | |
| 1713 | − cancelProjectRename(); | |
| 1714 | − } | |
| 1715 | − }} | |
| 1716 | − onBlur={() => { | |
| 1717 | − if (!projectRenamingCommittedRef.current) { | |
| 1718 | − void commitProjectRename(project.id, renamingProjectTitle, project.name); | |
| 1719 | − } | |
| 1720 | − }} | |
| 1721 | − onClick={(event) => event.stopPropagation()} | |
| 1722 | − onPointerDown={(event) => event.stopPropagation()} | |
| 1723 | − /> | |
| 1724 | − ) : ( | |
| 1725 | − <span className="flex-1 truncate text-xs font-medium text-foreground/90"> | |
| 1726 | − {project.name} | |
| 1727 | − </span> | |
| 1728 | − )} | |
| 1729 | − </SidebarMenuButton> | |
| 1730 | − <Tooltip> | |
| 1731 | − <TooltipTrigger | |
| 1732 | − render={ | |
| 1733 | − <SidebarMenuAction | |
| 1734 | − render={ | |
| 1735 | − <button | |
| 1736 | − type="button" | |
| 1737 | − aria-label={`Create new thread in ${project.name}`} | |
| 1738 | − data-testid="new-thread-button" | |
| 1739 | − /> | |
| 1740 | − } | |
| 1741 | − showOnHover | |
| 1742 | − className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" | |
| 1743 | − onClick={(event) => { | |
| 1744 | − event.preventDefault(); | |
| 1745 | − event.stopPropagation(); | |
| 1746 | − const seedContext = resolveSidebarNewThreadSeedContext({ | |
| 1747 | − projectId: project.id, | |
| 1748 | − defaultEnvMode: resolveSidebarNewThreadEnvMode({ | |
| 1749 | − defaultEnvMode: appSettings.defaultThreadEnvMode, | |
| 1750 | − }), | |
| 1751 | − activeThread: | |
| 1752 | − activeThread && activeThread.projectId === project.id | |
| 1753 | − ? { | |
| 1754 | − projectId: activeThread.projectId, | |
| 1755 | − branch: activeThread.branch, | |
| 1756 | − worktreePath: activeThread.worktreePath, | |
| 1757 | − } | |
| 1758 | − : null, | |
| 1759 | − activeDraftThread: | |
| 1760 | − activeDraftThread && activeDraftThread.projectId === project.id | |
| 1761 | − ? { | |
| 1762 | − projectId: activeDraftThread.projectId, | |
| 1763 | − branch: activeDraftThread.branch, | |
| 1764 | − worktreePath: activeDraftThread.worktreePath, | |
| 1765 | − envMode: activeDraftThread.envMode, | |
| 1766 | − } | |
| 1767 | − : null, | |
| 1768 | − }); | |
| 1769 | − void handleNewThread(project.id, { | |
| 1770 | − ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), | |
| 1771 | − ...(seedContext.worktreePath !== undefined | |
| 1772 | − ? { worktreePath: seedContext.worktreePath } | |
| 1773 | − : {}), | |
| 1774 | − envMode: seedContext.envMode, | |
| 1775 | − }); | |
| 1776 | − }} | |
| 1777 | − > | |
| 1778 | − <SquarePenIcon className="size-3.5" /> | |
| 1779 | − </SidebarMenuAction> | |
| 1780 | − } | |
| 1781 | − /> | |
| 1782 | − <TooltipPopup side="top"> | |
| 1783 | − {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} | |
| 1784 | − </TooltipPopup> | |
| 1785 | − </Tooltip> | |
| 1786 | − </div> | |
| 1787 | − | |
| 1788 | − <SidebarMenuSub | |
| 1789 | − ref={attachThreadListAutoAnimateRef} | |
| 1790 | − className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0" | |
| 1791 | − > | |
| 1792 | − {shouldShowThreadPanel && showEmptyThreadState ? ( | |
| 1793 | − <SidebarMenuSubItem className="w-full" data-thread-selection-safe> | |
| 1794 | − <div | |
| 1795 | − data-thread-selection-safe | |
| 1796 | − className="flex h-6 w-full translate-x-0 items-center px-2 text-left text-[10px] text-muted-foreground/60" | |
| 1797 | − > | |
| 1798 | − <span>No threads yet</span> | |
| 1799 | − </div> | |
| 1800 | − </SidebarMenuSubItem> | |
| 1801 | − ) : null} | |
| 1802 | − {shouldShowThreadPanel && | |
| 1803 | − renderedThreadIds.map((threadId) => ( | |
| 1804 | − <SidebarThreadRow | |
| 1805 | − key={threadId} | |
| 1806 | − threadId={threadId} | |
| 1807 | − projectCwd={project.cwd} | |
| 1808 | − orderedProjectThreadIds={orderedProjectThreadIds} | |
| 1809 | − routeThreadId={routeThreadId} | |
| 1810 | − selectedThreadIds={selectedThreadIds} | |
| 1811 | − showThreadJumpHints={showThreadJumpHints} | |
| 1812 | − jumpLabel={threadJumpLabelById.get(threadId) ?? null} | |
| 1813 | − appSettingsConfirmThreadArchive={appSettings.confirmThreadArchive} | |
| 1814 | − renamingThreadId={renamingThreadId} | |
| 1815 | − renamingTitle={renamingTitle} | |
| 1816 | − setRenamingTitle={setRenamingTitle} | |
| 1817 | − onRenamingInputMount={handleRenamingInputMount} | |
| 1818 | − hasRenameCommitted={hasRenameCommitted} | |
| 1819 | − markRenameCommitted={markRenameCommitted} | |
| 1820 | − confirmingArchiveThreadId={confirmingArchiveThreadId} | |
| 1821 | − setConfirmingArchiveThreadId={setConfirmingArchiveThreadId} | |
| 1822 | − confirmArchiveButtonRefs={confirmArchiveButtonRefs} | |
| 1823 | − handleThreadClick={handleThreadClick} | |
| 1824 | − navigateToThread={navigateToThread} | |
| 1825 | − handleMultiSelectContextMenu={handleMultiSelectContextMenu} | |
| 1826 | − handleThreadContextMenu={handleThreadContextMenu} | |
| 1827 | − clearSelection={clearSelection} | |
| 1828 | − commitRename={commitRename} | |
| 1829 | − cancelRename={cancelRename} | |
| 1830 | − attemptArchiveThread={attemptArchiveThread} | |
| 1831 | − openPrLink={openPrLink} | |
| 1832 | − /> | |
| 1833 | − ))} | |
| 1834 | − | |
| 1835 | − {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( | |
| 1836 | − <SidebarMenuSubItem className="w-full"> | |
| 1837 | − <SidebarMenuSubButton | |
| 1838 | − render={<button type="button" />} | |
| 1839 | − data-thread-selection-safe | |
| 1840 | − size="sm" | |
| 1841 | − className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" | |
| 1842 | − onClick={() => { | |
| 1843 | − expandThreadListForProject(project.id); | |
| 1844 | − }} | |
| 1845 | − > | |
| 1846 | − <span className="flex min-w-0 flex-1 items-center gap-2"> | |
| 1847 | − {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />} | |
| 1848 | − <span>Show more</span> | |
| 1849 | − </span> | |
| 1850 | − </SidebarMenuSubButton> | |
| 1851 | − </SidebarMenuSubItem> | |
| 1852 | − )} | |
| 1853 | − {project.expanded && hasHiddenThreads && isThreadListExpanded && ( | |
| 1854 | − <SidebarMenuSubItem className="w-full"> | |
| 1855 | − <SidebarMenuSubButton | |
| 1856 | − render={<button type="button" />} | |
| 1857 | − data-thread-selection-safe | |
| 1858 | − size="sm" | |
| 1859 | − className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" | |
| 1860 | − onClick={() => { | |
| 1861 | − collapseThreadListForProject(project.id); | |
| 1862 | − }} | |
| 1863 | − > | |
| 1864 | − <span>Show less</span> | |
| 1865 | − </SidebarMenuSubButton> | |
| 1866 | − </SidebarMenuSubItem> | |
| 1867 | − )} | |
| 1868 | − </SidebarMenuSub> | |
| 1869 | − </> | |
| 1870 | − ); | |
| 1871 | − } | |
| 1872 | − | |
| 1873 | − const handleProjectTitleClick = useCallback( | |
| 1874 | − (event: MouseEvent<HTMLButtonElement>, projectId: ProjectId) => { | |
| 1875 | − if (suppressProjectClickForContextMenuRef.current) { | |
| 1876 | − suppressProjectClickForContextMenuRef.current = false; | |
| 1877 | − event.preventDefault(); | |
| 1878 | − event.stopPropagation(); | |
| 1879 | − return; | |
| 1880 | − } | |
| 1881 | − if (dragInProgressRef.current) { | |
| 1882 | − event.preventDefault(); | |
| 1883 | − event.stopPropagation(); | |
| 1884 | − return; | |
| 1885 | − } | |
| 1886 | − if (suppressProjectClickAfterDragRef.current) { | |
| 1887 | − // Consume the synthetic click emitted after a drag release. | |
| 1888 | − suppressProjectClickAfterDragRef.current = false; | |
| 1889 | − event.preventDefault(); | |
| 1890 | − event.stopPropagation(); | |
| 1891 | − return; | |
| 1892 | − } | |
| 1893 | − if (selectedThreadIds.size > 0) { | |
| 1894 | − clearSelection(); | |
| 1895 | − } | |
| 1896 | − toggleProject(projectId); | |
| 1897 | − }, | |
| 1898 | − [clearSelection, selectedThreadIds.size, toggleProject], | |
| 1899 | − ); | |
| 1900 | − | |
| 1901 | − const handleProjectTitleKeyDown = useCallback( | |
| 1902 | − (event: KeyboardEvent<HTMLButtonElement>, projectId: ProjectId) => { | |
| 1903 | − if (event.key !== "Enter" && event.key !== " ") return; | |
| 1904 | − event.preventDefault(); | |
| 1905 | − if (dragInProgressRef.current) { | |
| 1906 | − return; | |
| 1907 | − } | |
| 1908 | − toggleProject(projectId); | |
| 1909 | − }, | |
| 1910 | − [toggleProject], | |
| 1911 | − ); | |
| 1912 | − | |
| 1913 | 2762 | useEffect(() => { |
| 1914 | 2763 | const onMouseDown = (event: globalThis.MouseEvent) => { |
| 1915 | − if (selectedThreadIds.size === 0) return; | |
| 2764 | + if (selectedThreadCount === 0) return; | |
| 1916 | 2765 | const target = event.target instanceof HTMLElement ? event.target : null; |
| 1917 | 2766 | if (!shouldClearThreadSelectionOnMouseDown(target)) return; |
| 1918 | 2767 | clearSelection(); |
| @@ -1922,7 +2771,7 @@ export default function Sidebar() { · 3 unmodified lines | ||
| 1922 | 2771 | return () => { |
| 1923 | 2772 | window.removeEventListener("mousedown", onMouseDown); |
| 1924 | 2773 | }; |
| 1925 | − }, [clearSelection, selectedThreadIds.size]); | |
| 2774 | + }, [clearSelection, selectedThreadCount]); | |
| 1926 | 2775 | |
| 1927 | 2776 | useEffect(() => { |
| 1928 | 2777 | if (!isElectron) return; |
| @@ -1967,10 +2816,6 @@ export default function Sidebar() { · 38 unmodified lines | ||
| 1967 | 2816 | desktopUpdateState && showArm64IntelBuildWarning |
| 1968 | 2817 | ? getArm64IntelBuildWarningDescription(desktopUpdateState) |
| 1969 | 2818 | : null; |
| 1970 | − const newThreadShortcutLabel = | |
| 1971 | − shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? | |
| 1972 | − shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); | |
| 1973 | − | |
| 1974 | 2819 | const handleDesktopUpdateButtonClick = useCallback(() => { |
| 1975 | 2820 | const bridge = window.desktopBridge; |
| 1976 | 2821 | if (!bridge || !desktopUpdateState) return; |
| @@ -2033,246 +2878,82 @@ export default function Sidebar() { · 56 unmodified lines | ||
| 2033 | 2878 | } |
| 2034 | 2879 | }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); |
| 2035 | 2880 | |
| 2036 | − const expandThreadListForProject = useCallback((projectId: ProjectId) => { | |
| 2881 | + const expandThreadListForProject = useCallback((projectKey: string) => { | |
| 2037 | 2882 | setExpandedThreadListsByProject((current) => { |
| 2038 | − if (current.has(projectId)) return current; | |
| 2883 | + if (current.has(projectKey)) return current; | |
| 2039 | 2884 | const next = new Set(current); |
| 2040 | − next.add(projectId); | |
| 2885 | + next.add(projectKey); | |
| 2041 | 2886 | return next; |
| 2042 | 2887 | }); |
| 2043 | 2888 | }, []); |
| 2044 | 2889 | |
| 2045 | − const collapseThreadListForProject = useCallback((projectId: ProjectId) => { | |
| 2890 | + const collapseThreadListForProject = useCallback((projectKey: string) => { | |
| 2046 | 2891 | setExpandedThreadListsByProject((current) => { |
| 2047 | − if (!current.has(projectId)) return current; | |
| 2892 | + if (!current.has(projectKey)) return current; | |
| 2048 | 2893 | const next = new Set(current); |
| 2049 | − next.delete(projectId); | |
| 2894 | + next.delete(projectKey); | |
| 2050 | 2895 | return next; |
| 2051 | 2896 | }); |
| 2052 | 2897 | }, []); |
| 2053 | 2898 | |
| 2054 | − const wordmark = ( | |
| 2055 | − <div className="flex items-center gap-2"> | |
| 2056 | − <SidebarTrigger className="shrink-0 md:hidden" /> | |
| 2057 | − <Tooltip> | |
| 2058 | − <TooltipTrigger | |
| 2059 | − render={ | |
| 2060 | − <Link | |
| 2061 | − aria-label="Go to threads" | |
| 2062 | − className="ml-1 flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md outline-hidden ring-ring transition-colors hover:text-foreground focus-visible:ring-2" | |
| 2063 | − to="/" | |
| 2064 | − > | |
| 2065 | − <T3Wordmark /> | |
| 2066 | − <span className="truncate text-sm font-medium tracking-tight text-muted-foreground"> | |
| 2067 | − Code | |
| 2068 | − </span> | |
| 2069 | − <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground/60"> | |
| 2070 | − {APP_STAGE_LABEL} | |
| 2071 | − </span> | |
| 2072 | − </Link> | |
| 2073 | − } | |
| 2074 | − /> | |
| 2075 | − <TooltipPopup side="bottom" sideOffset={2}> | |
| 2076 | − Version {APP_VERSION} | |
| 2077 | − </TooltipPopup> | |
| 2078 | − </Tooltip> | |
| 2079 | − </div> | |
| 2080 | − ); | |
| 2081 | − | |
| 2082 | 2899 | return ( |
| 2083 | 2900 | <> |
| 2084 | − {isElectron ? ( | |
| 2085 | − <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]"> | |
| 2086 | − {wordmark} | |
| 2087 | − </SidebarHeader> | |
| 2088 | − ) : ( | |
| 2089 | − <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3"> | |
| 2090 | − {wordmark} | |
| 2091 | − </SidebarHeader> | |
| 2092 | − )} | |
| 2901 | + <SidebarChromeHeader isElectron={isElectron} /> | |
| 2093 | 2902 | |
| 2094 | 2903 | {isOnSettings ? ( |
| 2095 | 2904 | <SettingsSidebarNav pathname={pathname} /> |
| 2096 | 2905 | ) : ( |
| 2097 | 2906 | <> |
| 2098 | − <SidebarContent className="gap-0"> | |
| 2099 | − {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( | |
| 2100 | − <SidebarGroup className="px-2 pt-2 pb-0"> | |
| 2101 | − <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8"> | |
| 2102 | − <TriangleAlertIcon /> | |
| 2103 | − <AlertTitle>Intel build on Apple Silicon</AlertTitle> | |
| 2104 | − <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription> | |
| 2105 | − {desktopUpdateButtonAction !== "none" ? ( | |
| 2106 | − <AlertAction> | |
| 2107 | − <Button | |
| 2108 | − size="xs" | |
| 2109 | − variant="outline" | |
| 2110 | − disabled={desktopUpdateButtonDisabled} | |
| 2111 | − onClick={handleDesktopUpdateButtonClick} | |
| 2112 | − > | |
| 2113 | − {desktopUpdateButtonAction === "download" | |
| 2114 | − ? "Download ARM build" | |
| 2115 | − : "Install ARM build"} | |
| 2116 | − </Button> | |
| 2117 | − </AlertAction> | |
| 2118 | − ) : null} | |
| 2119 | − </Alert> | |
| 2120 | − </SidebarGroup> | |
| 2121 | − ) : null} | |
| 2122 | − <SidebarGroup className="px-2 py-2"> | |
| 2123 | − <div className="mb-1 flex items-center justify-between pl-2 pr-1.5"> | |
| 2124 | − <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60"> | |
| 2125 | − Projects | |
| 2126 | − </span> | |
| 2127 | − <div className="flex items-center gap-1"> | |
| 2128 | − <ProjectSortMenu | |
| 2129 | − projectSortOrder={appSettings.sidebarProjectSortOrder} | |
| 2130 | − threadSortOrder={appSettings.sidebarThreadSortOrder} | |
| 2131 | − onProjectSortOrderChange={(sortOrder) => { | |
| 2132 | − updateSettings({ sidebarProjectSortOrder: sortOrder }); | |
| 2133 | − }} | |
| 2134 | − onThreadSortOrderChange={(sortOrder) => { | |
| 2135 | − updateSettings({ sidebarThreadSortOrder: sortOrder }); | |
| 2136 | − }} | |
| 2137 | − /> | |
| 2138 | − <Tooltip> | |
| 2139 | − <TooltipTrigger | |
| 2140 | − render={ | |
| 2141 | − <button | |
| 2142 | − type="button" | |
| 2143 | − aria-label={ | |
| 2144 | − shouldShowProjectPathEntry ? "Cancel add project" : "Add project" | |
| 2145 | − } | |
| 2146 | − aria-pressed={shouldShowProjectPathEntry} | |
| 2147 | − className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" | |
| 2148 | − onClick={handleStartAddProject} | |
| 2149 | − /> | |
| 2150 | − } | |
| 2151 | − > | |
| 2152 | − <PlusIcon | |
| 2153 | − className={`size-3.5 transition-transform duration-150 ${ | |
| 2154 | − shouldShowProjectPathEntry ? "rotate-45" : "rotate-0" | |
| 2155 | − }`} | |
| 2156 | − /> | |
| 2157 | − </TooltipTrigger> | |
| 2158 | − <TooltipPopup side="right"> | |
| 2159 | − {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} | |
| 2160 | − </TooltipPopup> | |
| 2161 | − </Tooltip> | |
| 2162 | − </div> | |
| 2163 | − </div> | |
| 2164 | − {shouldShowProjectPathEntry && ( | |
| 2165 | − <div className="mb-2 px-1"> | |
| 2166 | − {isElectron && ( | |
| 2167 | − <button | |
| 2168 | − type="button" | |
| 2169 | − className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60" | |
| 2170 | − onClick={() => void handlePickFolder()} | |
| 2171 | − disabled={isPickingFolder || isAddingProject} | |
| 2172 | − > | |
| 2173 | − <FolderIcon className="size-3.5" /> | |
| 2174 | − {isPickingFolder ? "Picking folder..." : "Browse for folder"} | |
| 2175 | − </button> | |
| 2176 | − )} | |
| 2177 | − <div className="flex gap-1.5"> | |
| 2178 | − <input | |
| 2179 | − ref={addProjectInputRef} | |
| 2180 | − className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${ | |
| 2181 | − addProjectError | |
| 2182 | − ? "border-red-500/70 focus:border-red-500" | |
| 2183 | − : "border-border focus:border-ring" | |
| 2184 | − }`} | |
| 2185 | − placeholder="/path/to/project" | |
| 2186 | − value={newCwd} | |
| 2187 | − onChange={(event) => { | |
| 2188 | − setNewCwd(event.target.value); | |
| 2189 | − setAddProjectError(null); | |
| 2190 | − }} | |
| 2191 | − onKeyDown={(event) => { | |
| 2192 | − if (event.key === "Enter") handleAddProject(); | |
| 2193 | − if (event.key === "Escape") { | |
| 2194 | − setAddingProject(false); | |
| 2195 | − setAddProjectError(null); | |
| 2196 | − } | |
| 2197 | − }} | |
| 2198 | − autoFocus | |
| 2199 | − /> | |
| 2200 | − <button | |
| 2201 | − type="button" | |
| 2202 | − className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60" | |
| 2203 | − onClick={handleAddProject} | |
| 2204 | − disabled={!canAddProject} | |
| 2205 | − > | |
| 2206 | − {isAddingProject ? "Adding..." : "Add"} | |
| 2207 | − </button> | |
| 2208 | − </div> | |
| 2209 | − {addProjectError && ( | |
| 2210 | − <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400"> | |
| 2211 | − {addProjectError} | |
| 2212 | − </p> | |
| 2213 | − )} | |
| 2214 | − </div> | |
| 2215 | − )} | |
| 2216 | − | |
| 2217 | − {isManualProjectSorting ? ( | |
| 2218 | − <DndContext | |
| 2219 | − sensors={projectDnDSensors} | |
| 2220 | − collisionDetection={projectCollisionDetection} | |
| 2221 | − modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]} | |
| 2222 | − onDragStart={handleProjectDragStart} | |
| 2223 | − onDragEnd={handleProjectDragEnd} | |
| 2224 | − onDragCancel={handleProjectDragCancel} | |
| 2225 | − > | |
| 2226 | − <SidebarMenu> | |
| 2227 | − <SortableContext | |
| 2228 | − items={renderedProjects.map((renderedProject) => renderedProject.project.id)} | |
| 2229 | − strategy={verticalListSortingStrategy} | |
| 2230 | − > | |
| 2231 | − {renderedProjects.map((renderedProject) => ( | |
| 2232 | − <SortableProjectItem | |
| 2233 | − key={renderedProject.project.id} | |
| 2234 | − projectId={renderedProject.project.id} | |
| 2235 | − > | |
| 2236 | − {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)} | |
| 2237 | − </SortableProjectItem> | |
| 2238 | − ))} | |
| 2239 | − </SortableContext> | |
| 2240 | − </SidebarMenu> | |
| 2241 | − </DndContext> | |
| 2242 | − ) : ( | |
| 2243 | − <SidebarMenu ref={attachProjectListAutoAnimateRef}> | |
| 2244 | − {renderedProjects.map((renderedProject) => ( | |
| 2245 | − <SidebarMenuItem key={renderedProject.project.id} className="rounded-md"> | |
| 2246 | − {renderProjectItem(renderedProject, null)} | |
| 2247 | − </SidebarMenuItem> | |
| 2248 | − ))} | |
| 2249 | − </SidebarMenu> | |
| 2250 | − )} | |
| 2251 | − | |
| 2252 | − {projects.length === 0 && !shouldShowProjectPathEntry && ( | |
| 2253 | − <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | |
| 2254 | − No projects yet | |
| 2255 | − </div> | |
| 2256 | − )} | |
| 2257 | − </SidebarGroup> | |
| 2258 | − </SidebarContent> | |
| 2907 | + <SidebarProjectsContent | |
| 2908 | + showArm64IntelBuildWarning={showArm64IntelBuildWarning} | |
| 2909 | + arm64IntelBuildWarningDescription={arm64IntelBuildWarningDescription} | |
| 2910 | + desktopUpdateButtonAction={desktopUpdateButtonAction} | |
| 2911 | + desktopUpdateButtonDisabled={desktopUpdateButtonDisabled} | |
| 2912 | + handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} | |
| 2913 | + projectSortOrder={appSettings.sidebarProjectSortOrder} | |
| 2914 | + threadSortOrder={appSettings.sidebarThreadSortOrder} | |
| 2915 | + updateSettings={updateSettings} | |
| 2916 | + shouldShowProjectPathEntry={shouldShowProjectPathEntry} | |
| 2917 | + handleStartAddProject={handleStartAddProject} | |
| 2918 | + isElectron={isElectron} | |
| 2919 | + isPickingFolder={isPickingFolder} | |
| 2920 | + isAddingProject={isAddingProject} | |
| 2921 | + handlePickFolder={handlePickFolder} | |
| 2922 | + addProjectInputRef={addProjectInputRef} | |
| 2923 | + addProjectError={addProjectError} | |
| 2924 | + newCwd={newCwd} | |
| 2925 | + setNewCwd={setNewCwd} | |
| 2926 | + setAddProjectError={setAddProjectError} | |
| 2927 | + handleAddProject={handleAddProject} | |
| 2928 | + setAddingProject={setAddingProject} | |
| 2929 | + canAddProject={canAddProject} | |
| 2930 | + isManualProjectSorting={isManualProjectSorting} | |
| 2931 | + projectDnDSensors={projectDnDSensors} | |
| 2932 | + projectCollisionDetection={projectCollisionDetection} | |
| 2933 | + handleProjectDragStart={handleProjectDragStart} | |
| 2934 | + handleProjectDragEnd={handleProjectDragEnd} | |
| 2935 | + handleProjectDragCancel={handleProjectDragCancel} | |
| 2936 | + handleNewThread={handleNewThread} | |
| 2937 | + archiveThread={archiveThread} | |
| 2938 | + deleteThread={deleteThread} | |
| 2939 | + sortedProjects={sortedProjects} | |
| 2940 | + expandedThreadListsByProject={expandedThreadListsByProject} | |
| 2941 | + activeRouteProjectKey={activeRouteProjectKey} | |
| 2942 | + routeThreadKey={routeThreadKey} | |
| 2943 | + newThreadShortcutLabel={newThreadShortcutLabel} | |
| 2944 | + threadJumpLabelByKey={visibleThreadJumpLabelByKey} | |
| 2945 | + attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} | |
| 2946 | + expandThreadListForProject={expandThreadListForProject} | |
| 2947 | + collapseThreadListForProject={collapseThreadListForProject} | |
| 2948 | + dragInProgressRef={dragInProgressRef} | |
| 2949 | + suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef} | |
| 2950 | + suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} | |
| 2951 | + attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef} | |
| 2952 | + projectsLength={projects.length} | |
| 2953 | + /> | |
| 2259 | 2954 | |
| 2260 | 2955 | <SidebarSeparator /> |
| 2261 | − <SidebarFooter className="p-2"> | |
| 2262 | − <SidebarUpdatePill /> | |
| 2263 | − <SidebarMenu> | |
| 2264 | − <SidebarMenuItem> | |
| 2265 | − <SidebarMenuButton | |
| 2266 | − size="sm" | |
| 2267 | − className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground" | |
| 2268 | − onClick={() => void navigate({ to: "/settings" })} | |
| 2269 | − > | |
| 2270 | − <SettingsIcon className="size-3.5" /> | |
| 2271 | − <span className="text-xs">Settings</span> | |
| 2272 | − </SidebarMenuButton> | |
| 2273 | − </SidebarMenuItem> | |
| 2274 | − </SidebarMenu> | |
| 2275 | − </SidebarFooter> | |
| 2956 | + <SidebarChromeFooter /> | |
| 2276 | 2957 | </> |
| 2277 | 2958 | )} |
| 2278 | 2959 | </> |
| @@ -0,0 +1,247 @@ | ||
| 1 | +import "../index.css"; | |
| 2 | + | |
| 3 | +import { scopeThreadRef } from "@t3tools/client-runtime"; | |
| 4 | +import { ThreadId } from "@t3tools/contracts"; | |
| 5 | +import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 6 | +import { render } from "vitest-browser-react"; | |
| 7 | + | |
| 8 | +const { | |
| 9 | + terminalConstructorSpy, | |
| 10 | + terminalDisposeSpy, | |
| 11 | + fitAddonFitSpy, | |
| 12 | + fitAddonLoadSpy, | |
| 13 | + environmentApiById, | |
| 14 | + readEnvironmentApiMock, | |
| 15 | + readLocalApiMock, | |
| 16 | +} = vi.hoisted(() => ({ | |
| 17 | + terminalConstructorSpy: vi.fn(), | |
| 18 | + terminalDisposeSpy: vi.fn(), | |
| 19 | + fitAddonFitSpy: vi.fn(), | |
| 20 | + fitAddonLoadSpy: vi.fn(), | |
| 21 | + environmentApiById: new Map<string, { terminal: { open: ReturnType<typeof vi.fn> } }>(), | |
| 22 | + readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), | |
| 23 | + readLocalApiMock: vi.fn< | |
| 24 | + () => | |
| 25 | + | { | |
| 26 | + contextMenu: { show: ReturnType<typeof vi.fn> }; | |
| 27 | + shell: { openExternal: ReturnType<typeof vi.fn> }; | |
| 28 | + } | |
| 29 | + | undefined | |
| 30 | + >(() => ({ | |
| 31 | + contextMenu: { show: vi.fn(async () => null) }, | |
| 32 | + shell: { openExternal: vi.fn(async () => undefined) }, | |
| 33 | + })), | |
| 34 | +})); | |
| 35 | + | |
| 36 | +vi.mock("@xterm/addon-fit", () => ({ | |
| 37 | + FitAddon: class MockFitAddon { | |
| 38 | + fit = fitAddonFitSpy; | |
| 39 | + }, | |
| 40 | +})); | |
| 41 | + | |
| 42 | +vi.mock("@xterm/xterm", () => ({ | |
| 43 | + Terminal: class MockTerminal { | |
| 44 | + cols = 80; | |
| 45 | + rows = 24; | |
| 46 | + options: { theme?: unknown } = {}; | |
| 47 | + buffer = { | |
| 48 | + active: { | |
| 49 | + viewportY: 0, | |
| 50 | + baseY: 0, | |
| 51 | + getLine: vi.fn(() => null), | |
| 52 | + }, | |
| 53 | + }; | |
| 54 | + | |
| 55 | + constructor(options: unknown) { | |
| 56 | + terminalConstructorSpy(options); | |
| 57 | + } | |
| 58 | + | |
| 59 | + loadAddon(addon: unknown) { | |
| 60 | + fitAddonLoadSpy(addon); | |
| 61 | + } | |
| 62 | + | |
| 63 | + open() {} | |
| 64 | + | |
| 65 | + write() {} | |
| 66 | + | |
| 67 | + clear() {} | |
| 68 | + | |
| 69 | + clearSelection() {} | |
| 70 | + | |
| 71 | + focus() {} | |
| 72 | + | |
| 73 | + refresh() {} | |
| 74 | + | |
| 75 | + scrollToBottom() {} | |
| 76 | + | |
| 77 | + hasSelection() { | |
| 78 | + return false; | |
| 79 | + } | |
| 80 | + | |
| 81 | + getSelection() { | |
| 82 | + return ""; | |
| 83 | + } | |
| 84 | + | |
| 85 | + getSelectionPosition() { | |
| 86 | + return null; | |
| 87 | + } | |
| 88 | + | |
| 89 | + attachCustomKeyEventHandler() { | |
| 90 | + return true; | |
| 91 | + } | |
| 92 | + | |
| 93 | + registerLinkProvider() { | |
| 94 | + return { dispose: vi.fn() }; | |
| 95 | + } | |
| 96 | + | |
| 97 | + onData() { | |
| 98 | + return { dispose: vi.fn() }; | |
| 99 | + } | |
| 100 | + | |
| 101 | + onSelectionChange() { | |
| 102 | + return { dispose: vi.fn() }; | |
| 103 | + } | |
| 104 | + | |
| 105 | + dispose() { | |
| 106 | + terminalDisposeSpy(); | |
| 107 | + } | |
| 108 | + }, | |
| 109 | +})); | |
| 110 | + | |
| 111 | +vi.mock("~/environmentApi", () => ({ | |
| 112 | + readEnvironmentApi: readEnvironmentApiMock, | |
| 113 | +})); | |
| 114 | + | |
| 115 | +vi.mock("~/localApi", () => ({ | |
| 116 | + readLocalApi: readLocalApiMock, | |
| 117 | +})); | |
| 118 | + | |
| 119 | +import { TerminalViewport } from "./ThreadTerminalDrawer"; | |
| 120 | + | |
| 121 | +const THREAD_ID = ThreadId.makeUnsafe("thread-terminal-browser"); | |
| 122 | + | |
| 123 | +function createEnvironmentApi() { | |
| 124 | + return { | |
| 125 | + terminal: { | |
| 126 | + open: vi.fn(async () => ({ | |
| 127 | + threadId: THREAD_ID, | |
| 128 | + terminalId: "default", | |
| 129 | + cwd: "/repo/project", | |
| 130 | + worktreePath: null, | |
| 131 | + status: "running" as const, | |
| 132 | + pid: 123, | |
| 133 | + history: "", | |
| 134 | + exitCode: null, | |
| 135 | + exitSignal: null, | |
| 136 | + updatedAt: "2026-04-07T00:00:00.000Z", | |
| 137 | + })), | |
| 138 | + write: vi.fn(async () => undefined), | |
| 139 | + resize: vi.fn(async () => undefined), | |
| 140 | + }, | |
| 141 | + }; | |
| 142 | +} | |
| 143 | + | |
| 144 | +async function mountTerminalViewport(props: { threadRef: ReturnType<typeof scopeThreadRef> }) { | |
| 145 | + const host = document.createElement("div"); | |
| 146 | + host.style.width = "800px"; | |
| 147 | + host.style.height = "400px"; | |
| 148 | + document.body.append(host); | |
| 149 | + | |
| 150 | + const screen = await render( | |
| 151 | + <TerminalViewport | |
| 152 | + threadRef={props.threadRef} | |
| 153 | + threadId={THREAD_ID} | |
| 154 | + terminalId="default" | |
| 155 | + terminalLabel="Terminal" | |
| 156 | + cwd="/repo/project" | |
| 157 | + onSessionExited={() => undefined} | |
| 158 | + onAddTerminalContext={() => undefined} | |
| 159 | + focusRequestId={0} | |
| 160 | + autoFocus={false} | |
| 161 | + resizeEpoch={0} | |
| 162 | + drawerHeight={320} | |
| 163 | + />, | |
| 164 | + { container: host }, | |
| 165 | + ); | |
| 166 | + | |
| 167 | + return { | |
| 168 | + rerender: async (nextProps: { threadRef: ReturnType<typeof scopeThreadRef> }) => { | |
| 169 | + await screen.rerender( | |
| 170 | + <TerminalViewport | |
| 171 | + threadRef={nextProps.threadRef} | |
| 172 | + threadId={THREAD_ID} | |
| 173 | + terminalId="default" | |
| 174 | + terminalLabel="Terminal" | |
| 175 | + cwd="/repo/project" | |
| 176 | + onSessionExited={() => undefined} | |
| 177 | + onAddTerminalContext={() => undefined} | |
| 178 | + focusRequestId={0} | |
| 179 | + autoFocus={false} | |
| 180 | + resizeEpoch={0} | |
| 181 | + drawerHeight={320} | |
| 182 | + />, | |
| 183 | + ); | |
| 184 | + }, | |
| 185 | + cleanup: async () => { | |
| 186 | + await screen.unmount(); | |
| 187 | + host.remove(); | |
| 188 | + }, | |
| 189 | + }; | |
| 190 | +} | |
| 191 | + | |
| 192 | +describe("TerminalViewport", () => { | |
| 193 | + afterEach(() => { | |
| 194 | + environmentApiById.clear(); | |
| 195 | + readEnvironmentApiMock.mockClear(); | |
| 196 | + readLocalApiMock.mockClear(); | |
| 197 | + terminalConstructorSpy.mockClear(); | |
| 198 | + terminalDisposeSpy.mockClear(); | |
| 199 | + fitAddonFitSpy.mockClear(); | |
| 200 | + fitAddonLoadSpy.mockClear(); | |
| 201 | + }); | |
| 202 | + | |
| 203 | + it("does not create a terminal when APIs are unavailable", async () => { | |
| 204 | + readEnvironmentApiMock.mockReturnValueOnce(undefined); | |
| 205 | + readLocalApiMock.mockReturnValueOnce(undefined); | |
| 206 | + | |
| 207 | + const mounted = await mountTerminalViewport({ | |
| 208 | + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | |
| 209 | + }); | |
| 210 | + | |
| 211 | + try { | |
| 212 | + await vi.waitFor(() => { | |
| 213 | + expect(terminalConstructorSpy).not.toHaveBeenCalled(); | |
| 214 | + }); | |
| 215 | + } finally { | |
| 216 | + await mounted.cleanup(); | |
| 217 | + } | |
| 218 | + }); | |
| 219 | + | |
| 220 | + it("reopens the terminal when the scoped thread reference changes", async () => { | |
| 221 | + const environmentA = createEnvironmentApi(); | |
| 222 | + const environmentB = createEnvironmentApi(); | |
| 223 | + environmentApiById.set("environment-a", environmentA); | |
| 224 | + environmentApiById.set("environment-b", environmentB); | |
| 225 | + | |
| 226 | + const mounted = await mountTerminalViewport({ | |
| 227 | + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | |
| 228 | + }); | |
| 229 | + | |
| 230 | + try { | |
| 231 | + await vi.waitFor(() => { | |
| 232 | + expect(environmentA.terminal.open).toHaveBeenCalledTimes(1); | |
| 233 | + }); | |
| 234 | + | |
| 235 | + await mounted.rerender({ | |
| 236 | + threadRef: scopeThreadRef("environment-b" as never, THREAD_ID), | |
| 237 | + }); | |
| 238 | + | |
| 239 | + await vi.waitFor(() => { | |
| 240 | + expect(environmentB.terminal.open).toHaveBeenCalledTimes(1); | |
| 241 | + }); | |
| 242 | + expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); | |
| 243 | + } finally { | |
| 244 | + await mounted.cleanup(); | |
| 245 | + } | |
| 246 | + }); | |
| 247 | +}); | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; |
| 3 | 3 | import { |
| 4 | + type ScopedThreadRef, | |
| 4 | 5 | type TerminalEvent, |
| 5 | 6 | type TerminalSessionSnapshot, |
| 6 | 7 | type ThreadId, |
| @@ -31,7 +32,8 @@ import { · 24 unmodified lines | ||
| 31 | 32 | MAX_TERMINALS_PER_GROUP, |
| 32 | 33 | type ThreadTerminalGroup, |
| 33 | 34 | } from "../types"; |
| 34 | −import { readNativeApi } from "~/nativeApi"; | |
| 35 | +import { readEnvironmentApi } from "~/environmentApi"; | |
| 36 | +import { readLocalApi } from "~/localApi"; | |
| 35 | 37 | import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; |
| 36 | 38 | |
| 37 | 39 | const MIN_DRAWER_HEIGHT = 180; |
| @@ -208,6 +210,7 @@ export function shouldHandleTerminalSelectionMouseUp( · 170 unmodified lines | ||
| 208 | 210 | } |
| 209 | 211 | |
| 210 | 212 | interface TerminalViewportProps { |
| 213 | + threadRef: ScopedThreadRef; | |
| 211 | 214 | threadId: ThreadId; |
| 212 | 215 | terminalId: string; |
| 213 | 216 | terminalLabel: string; |
| @@ -222,7 +225,8 @@ interface TerminalViewportProps { · 8 unmodified lines | ||
| 222 | 225 | drawerHeight: number; |
| 223 | 226 | } |
| 224 | 227 | |
| 225 | −function TerminalViewport({ | |
| 228 | +export function TerminalViewport({ | |
| 229 | + threadRef, | |
| 226 | 230 | threadId, |
| 227 | 231 | terminalId, |
| 228 | 232 | terminalLabel, |
| @@ -260,6 +264,9 @@ function TerminalViewport({ · 31 unmodified lines | ||
| 260 | 264 | if (!mount) return; |
| 261 | 265 | |
| 262 | 266 | let disposed = false; |
| 267 | + const api = readEnvironmentApi(threadRef.environmentId); | |
| 268 | + const localApi = readLocalApi(); | |
| 269 | + if (!api || !localApi) return; | |
| 263 | 270 | |
| 264 | 271 | const fitAddon = new FitAddon(); |
| 265 | 272 | const terminal = new Terminal({ |
| @@ -277,9 +284,6 @@ function TerminalViewport({ · 11 unmodified lines | ||
| 277 | 284 | terminalRef.current = terminal; |
| 278 | 285 | fitAddonRef.current = fitAddon; |
| 279 | 286 | |
| 280 | − const api = readNativeApi(); | |
| 281 | − if (!api) return; | |
| 282 | − | |
| 283 | 287 | const clearSelectionAction = () => { |
| 284 | 288 | selectionActionRequestIdRef.current += 1; |
| 285 | 289 | if (selectionActionTimerRef.current !== null) { |
| @@ -340,7 +344,7 @@ function TerminalViewport({ · 54 unmodified lines | ||
| 340 | 344 | const requestId = ++selectionActionRequestIdRef.current; |
| 341 | 345 | selectionActionOpenRef.current = true; |
| 342 | 346 | try { |
| 343 | − const clicked = await api.contextMenu.show( | |
| 347 | + const clicked = await localApi.contextMenu.show( | |
| 344 | 348 | [{ id: "add-to-chat", label: "Add to chat" }], |
| 345 | 349 | nextAction.position, |
| 346 | 350 | ); |
| @@ -416,7 +420,7 @@ function TerminalViewport({ · 69 unmodified lines | ||
| 416 | 420 | if (!latestTerminal) return; |
| 417 | 421 | |
| 418 | 422 | if (match.kind === "url") { |
| 419 | − void api.shell.openExternal(match.text).catch((error) => { | |
| 423 | + void localApi.shell.openExternal(match.text).catch((error: unknown) => { | |
| 420 | 424 | writeSystemMessage( |
| 421 | 425 | latestTerminal, |
| 422 | 426 | error instanceof Error ? error.message : "Unable to open link", |
| @@ -426,7 +430,7 @@ function TerminalViewport({ · 3 unmodified lines | ||
| 426 | 430 | } |
| 427 | 431 | |
| 428 | 432 | const target = resolvePathLinkTarget(match.text, cwd); |
| 429 | − void openInPreferredEditor(api, target).catch((error) => { | |
| 433 | + void openInPreferredEditor(localApi, target).catch((error) => { | |
| 430 | 434 | writeSystemMessage( |
| 431 | 435 | latestTerminal, |
| 432 | 436 | error instanceof Error ? error.message : "Unable to open path", |
| @@ -573,12 +577,12 @@ function TerminalViewport({ · 140 unmodified lines | ||
| 573 | 577 | const previousLastEntryId = |
| 574 | 578 | selectTerminalEventEntries( |
| 575 | 579 | previousState.terminalEventEntriesByKey, |
| 576 | − threadId, | |
| 580 | + threadRef, | |
| 577 | 581 | terminalId, |
| 578 | 582 | ).at(-1)?.id ?? 0; |
| 579 | 583 | const nextEntries = selectTerminalEventEntries( |
| 580 | 584 | state.terminalEventEntriesByKey, |
| 581 | − threadId, | |
| 585 | + threadRef, | |
| 582 | 586 | terminalId, |
| 583 | 587 | ); |
| 584 | 588 | const nextLastEntryId = nextEntries.at(-1)?.id ?? 0; |
| @@ -608,7 +612,7 @@ function TerminalViewport({ · 23 unmodified lines | ||
| 608 | 612 | writeTerminalSnapshot(activeTerminal, snapshot); |
| 609 | 613 | const bufferedEntries = selectTerminalEventEntries( |
| 610 | 614 | useTerminalStateStore.getState().terminalEventEntriesByKey, |
| 611 | − threadId, | |
| 615 | + threadRef, | |
| 612 | 616 | terminalId, |
| 613 | 617 | ); |
| 614 | 618 | const replayEntries = selectTerminalEventEntriesAfterSnapshot( |
| @@ -677,7 +681,7 @@ function TerminalViewport({ · 62 unmodified lines | ||
| 677 | 681 | // autoFocus is intentionally omitted; |
| 678 | 682 | // it is only read at mount time and must not trigger terminal teardown/recreation. |
| 679 | 683 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 680 | − }, [cwd, runtimeEnv, terminalId, threadId]); | |
| 684 | + }, [cwd, runtimeEnv, terminalId, threadId, threadRef]); | |
| 681 | 685 | |
| 682 | 686 | useEffect(() => { |
| 683 | 687 | if (!autoFocus) return; |
| @@ -692,7 +696,7 @@ function TerminalViewport({ · 8 unmodified lines | ||
| 692 | 696 | }, [autoFocus, focusRequestId]); |
| 693 | 697 | |
| 694 | 698 | useEffect(() => { |
| 695 | − const api = readNativeApi(); | |
| 699 | + const api = readEnvironmentApi(threadRef.environmentId); | |
| 696 | 700 | const terminal = terminalRef.current; |
| 697 | 701 | const fitAddon = fitAddonRef.current; |
| 698 | 702 | if (!api || !terminal || !fitAddon) return; |
| @@ -714,13 +718,14 @@ function TerminalViewport({ · 15 unmodified lines | ||
| 714 | 718 | return () => { |
| 715 | 719 | window.cancelAnimationFrame(frame); |
| 716 | 720 | }; |
| 717 | − }, [drawerHeight, resizeEpoch, terminalId, threadId]); | |
| 721 | + }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]); | |
| 718 | 722 | return ( |
| 719 | 723 | <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-[4px]" /> |
| 720 | 724 | ); |
| 721 | 725 | } |
| 722 | 726 | |
| 723 | 727 | interface ThreadTerminalDrawerProps { |
| 728 | + threadRef: ScopedThreadRef; | |
| 724 | 729 | threadId: ThreadId; |
| 725 | 730 | cwd: string; |
| 726 | 731 | worktreePath?: string | null; |
| @@ -773,6 +778,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA · 46 unmodified lines | ||
| 773 | 778 | } |
| 774 | 779 | |
| 775 | 780 | export default function ThreadTerminalDrawer({ |
| 781 | + threadRef, | |
| 776 | 782 | threadId, |
| 777 | 783 | cwd, |
| 778 | 784 | worktreePath, |
| @@ -1098,6 +1104,7 @@ export default function ThreadTerminalDrawer({ · 319 unmodified lines | ||
| 1098 | 1104 | > |
| 1099 | 1105 | <div className="h-full p-1"> |
| 1100 | 1106 | <TerminalViewport |
| 1107 | + threadRef={threadRef} | |
| 1101 | 1108 | threadId={threadId} |
| 1102 | 1109 | terminalId={terminalId} |
| 1103 | 1110 | terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} |
| @@ -1119,6 +1126,7 @@ export default function ThreadTerminalDrawer({ · 15 unmodified lines | ||
| 1119 | 1126 | <div className="h-full p-1"> |
| 1120 | 1127 | <TerminalViewport |
| 1121 | 1128 | key={resolvedActiveTerminalId} |
| 1129 | + threadRef={threadRef} | |
| 1122 | 1130 | threadId={threadId} |
| 1123 | 1131 | terminalId={resolvedActiveTerminalId} |
| 1124 | 1132 | terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} |
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | import { |
| 2 | + type EnvironmentId, | |
| 2 | 3 | type EditorId, |
| 3 | 4 | type ProjectScript, |
| 4 | 5 | type ResolvedKeybindingsConfig, |
| 5 | 6 | type ThreadId, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 8 | +import { scopeThreadRef } from "@t3tools/client-runtime"; | |
| 7 | 9 | import { memo } from "react"; |
| 8 | 10 | import GitActionsControl from "../GitActionsControl"; |
| 9 | 11 | import { DiffIcon, TerminalSquareIcon } from "lucide-react"; |
| @@ -15,6 +17,7 @@ import { SidebarTrigger } from "../ui/sidebar"; · 5 unmodified lines | ||
| 15 | 17 | import { OpenInPicker } from "./OpenInPicker"; |
| 16 | 18 | |
| 17 | 19 | interface ChatHeaderProps { |
| 20 | + activeThreadEnvironmentId: EnvironmentId; | |
| 18 | 21 | activeThreadId: ThreadId; |
| 19 | 22 | activeThreadTitle: string; |
| 20 | 23 | activeProjectName: string | undefined; |
| @@ -39,6 +42,7 @@ interface ChatHeaderProps { · 18 unmodified lines | ||
| 39 | 42 | } |
| 40 | 43 | |
| 41 | 44 | export const ChatHeader = memo(function ChatHeader({ |
| 45 | + activeThreadEnvironmentId, | |
| 42 | 46 | activeThreadId, |
| 43 | 47 | activeThreadTitle, |
| 44 | 48 | activeProjectName, |
| @@ -101,7 +105,12 @@ export const ChatHeader = memo(function ChatHeader({ · 56 unmodified lines | ||
| 101 | 105 | openInCwd={openInCwd} |
| 102 | 106 | /> |
| 103 | 107 | )} |
| 104 | − {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />} | |
| 108 | + {activeProjectName && ( | |
| 109 | + <GitActionsControl | |
| 110 | + gitCwd={gitCwd} | |
| 111 | + activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)} | |
| 112 | + /> | |
| 113 | + )} | |
| 105 | 114 | <Tooltip> |
| 106 | 115 | <TooltipTrigger |
| 107 | 116 | render={ |
| @@ -1,4 +1,10 @@ | ||
| 1 | −import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts"; | |
| 1 | +import { | |
| 2 | + DEFAULT_MODEL_BY_PROVIDER, | |
| 3 | + EnvironmentId, | |
| 4 | + ModelSelection, | |
| 5 | + ThreadId, | |
| 6 | +} from "@t3tools/contracts"; | |
| 7 | +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | |
| 2 | 8 | import "../../index.css"; |
| 3 | 9 | |
| 4 | 10 | import { page } from "vitest/browser"; |
| @@ -9,35 +15,37 @@ import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; · 4 unmodified lines | ||
| 9 | 15 | import { TraitsMenuContent } from "./TraitsPicker"; |
| 10 | 16 | import { useComposerDraftStore } from "../../composerDraftStore"; |
| 11 | 17 | |
| 18 | +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); | |
| 19 | + | |
| 12 | 20 | async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) { |
| 13 | 21 | const threadId = ThreadId.makeUnsafe("thread-compact-menu"); |
| 22 | + const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); | |
| 23 | + const threadKey = scopedThreadKey(threadRef); | |
| 14 | 24 | const provider = props?.modelSelection?.provider ?? "claudeAgent"; |
| 15 | − const draftsByThreadId = {} as ReturnType< | |
| 16 | − typeof useComposerDraftStore.getState | |
| 17 | − >["draftsByThreadId"]; | |
| 18 | 25 | const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; |
| 19 | 26 | |
| 20 | − draftsByThreadId[threadId] = { | |
| 21 | − prompt: props?.prompt ?? "", | |
| 22 | − images: [], | |
| 23 | − nonPersistedImageIds: [], | |
| 24 | − persistedAttachments: [], | |
| 25 | − terminalContexts: [], | |
| 26 | − modelSelectionByProvider: { | |
| 27 | − [provider]: { | |
| 28 | − provider, | |
| 29 | − model, | |
| 30 | − ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), | |
| 27 | + useComposerDraftStore.setState({ | |
| 28 | + draftsByThreadKey: { | |
| 29 | + [threadKey]: { | |
| 30 | + prompt: props?.prompt ?? "", | |
| 31 | + images: [], | |
| 32 | + nonPersistedImageIds: [], | |
| 33 | + persistedAttachments: [], | |
| 34 | + terminalContexts: [], | |
| 35 | + modelSelectionByProvider: { | |
| 36 | + [provider]: { | |
| 37 | + provider, | |
| 38 | + model, | |
| 39 | + ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), | |
| 40 | + }, | |
| 41 | + }, | |
| 42 | + activeProvider: provider, | |
| 43 | + runtimeMode: null, | |
| 44 | + interactionMode: null, | |
| 31 | 45 | }, |
| 32 | 46 | }, |
| 33 | − activeProvider: provider, | |
| 34 | − runtimeMode: null, | |
| 35 | − interactionMode: null, | |
| 36 | − }; | |
| 37 | − useComposerDraftStore.setState({ | |
| 38 | − draftsByThreadId, | |
| 39 | − draftThreadsByThreadId: {}, | |
| 40 | − projectDraftThreadIdByProjectId: {}, | |
| 47 | + draftThreadsByThreadKey: {}, | |
| 48 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 41 | 49 | }); |
| 42 | 50 | const host = document.createElement("div"); |
| 43 | 51 | document.body.append(host); |
| @@ -121,7 +129,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str · 77 unmodified lines | ||
| 121 | 129 | <TraitsMenuContent |
| 122 | 130 | provider={provider} |
| 123 | 131 | models={models} |
| 124 | − threadId={threadId} | |
| 132 | + threadRef={threadRef} | |
| 125 | 133 | model={model} |
| 126 | 134 | prompt={props?.prompt ?? ""} |
| 127 | 135 | modelOptions={providerOptions} |
| @@ -150,9 +158,9 @@ describe("CompactComposerControlsMenu", () => { · 22 unmodified lines | ||
| 150 | 158 | afterEach(() => { |
| 151 | 159 | document.body.innerHTML = ""; |
| 152 | 160 | useComposerDraftStore.setState({ |
| 153 | − draftsByThreadId: {}, | |
| 154 | − draftThreadsByThreadId: {}, | |
| 155 | − projectDraftThreadIdByProjectId: {}, | |
| 161 | + draftsByThreadKey: {}, | |
| 162 | + draftThreadsByThreadKey: {}, | |
| 163 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 156 | 164 | stickyModelSelectionByProvider: {}, |
| 157 | 165 | }); |
| 158 | 166 | }); |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { type ApprovalRequestId } from "@t3tools/contracts"; |
| 2 | −import { memo, useCallback, useEffect, useRef } from "react"; | |
| 2 | +import { memo, useEffect, useEffectEvent, useRef } from "react"; | |
| 3 | 3 | import { type PendingUserInput } from "../../session-logic"; |
| 4 | 4 | import { |
| 5 | 5 | derivePendingUserInputProgress, |
| @@ -60,6 +60,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 54 unmodified lines | ||
| 60 | 60 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); |
| 61 | 61 | const activeQuestion = progress.activeQuestion; |
| 62 | 62 | const autoAdvanceTimerRef = useRef<number | null>(null); |
| 63 | + const onAdvanceRef = useRef(onAdvance); | |
| 64 | + | |
| 65 | + useEffect(() => { | |
| 66 | + onAdvanceRef.current = onAdvance; | |
| 67 | + }, [onAdvance]); | |
| 63 | 68 | |
| 64 | 69 | // Clear auto-advance timer on unmount |
| 65 | 70 | useEffect(() => { |
| @@ -70,22 +75,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 4 unmodified lines | ||
| 70 | 75 | }; |
| 71 | 76 | }, []); |
| 72 | 77 | |
| 73 | − const handleOptionSelection = useCallback( | |
| 74 | − (questionId: string, optionLabel: string) => { | |
| 75 | − onToggleOption(questionId, optionLabel); | |
| 76 | − if (activeQuestion?.multiSelect) { | |
| 77 | − return; | |
| 78 | − } | |
| 79 | − if (autoAdvanceTimerRef.current !== null) { | |
| 80 | − window.clearTimeout(autoAdvanceTimerRef.current); | |
| 81 | − } | |
| 82 | − autoAdvanceTimerRef.current = window.setTimeout(() => { | |
| 83 | − autoAdvanceTimerRef.current = null; | |
| 84 | − onAdvance(); | |
| 85 | − }, 200); | |
| 86 | − }, | |
| 87 | − [activeQuestion?.multiSelect, onAdvance, onToggleOption], | |
| 88 | − ); | |
| 78 | + const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => { | |
| 79 | + onToggleOption(questionId, optionLabel); | |
| 80 | + if (activeQuestion?.multiSelect) { | |
| 81 | + return; | |
| 82 | + } | |
| 83 | + if (autoAdvanceTimerRef.current !== null) { | |
| 84 | + window.clearTimeout(autoAdvanceTimerRef.current); | |
| 85 | + } | |
| 86 | + autoAdvanceTimerRef.current = window.setTimeout(() => { | |
| 87 | + autoAdvanceTimerRef.current = null; | |
| 88 | + onAdvanceRef.current(); | |
| 89 | + }, 200); | |
| 90 | + }); | |
| 89 | 91 | |
| 90 | 92 | // Keyboard shortcut: number keys 1-9 select corresponding options when focus is |
| 91 | 93 | // outside editable fields. Multi-select prompts toggle options in place; single- |
| @@ -112,7 +114,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 20 unmodified lines | ||
| 112 | 114 | }; |
| 113 | 115 | document.addEventListener("keydown", handler); |
| 114 | 116 | return () => document.removeEventListener("keydown", handler); |
| 115 | − }, [activeQuestion, handleOptionSelection, isResponding]); | |
| 117 | + }, [activeQuestion, isResponding]); | |
| 116 | 118 | |
| 117 | 119 | if (!activeQuestion) { |
| 118 | 120 | return null; |
| @@ -42,6 +42,8 @@ beforeAll(() => { · 41 unmodified lines | ||
| 42 | 42 | }); |
| 43 | 43 | }); |
| 44 | 44 | |
| 45 | +const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; | |
| 46 | + | |
| 45 | 47 | describe("MessagesTimeline", () => { |
| 46 | 48 | it("renders inline terminal labels with the composer chip UI", async () => { |
| 47 | 49 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| @@ -85,6 +87,7 @@ describe("MessagesTimeline", () => { · 37 unmodified lines | ||
| 85 | 87 | onRevertUserMessage={() => {}} |
| 86 | 88 | isRevertingCheckpoint={false} |
| 87 | 89 | onImageExpand={() => {}} |
| 90 | + activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} | |
| 88 | 91 | markdownCwd={undefined} |
| 89 | 92 | resolvedTheme="light" |
| 90 | 93 | timestampFormat="locale" |
| @@ -130,6 +133,7 @@ describe("MessagesTimeline", () => { · 39 unmodified lines | ||
| 130 | 133 | onRevertUserMessage={() => {}} |
| 131 | 134 | isRevertingCheckpoint={false} |
| 132 | 135 | onImageExpand={() => {}} |
| 136 | + activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} | |
| 133 | 137 | markdownCwd={undefined} |
| 134 | 138 | resolvedTheme="light" |
| 135 | 139 | timestampFormat="locale" |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { type MessageId, type TurnId } from "@t3tools/contracts"; | |
| 1 | +import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts"; | |
| 2 | 2 | import { |
| 3 | 3 | memo, |
| 4 | 4 | useCallback, |
| @@ -82,6 +82,7 @@ interface MessagesTimelineProps { · 77 unmodified lines | ||
| 82 | 82 | onRevertUserMessage: (messageId: MessageId) => void; |
| 83 | 83 | isRevertingCheckpoint: boolean; |
| 84 | 84 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 85 | + activeThreadEnvironmentId: EnvironmentId; | |
| 85 | 86 | markdownCwd: string | undefined; |
| 86 | 87 | resolvedTheme: "light" | "dark"; |
| 87 | 88 | timestampFormat: TimestampFormat; |
| @@ -117,6 +118,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 29 unmodified lines | ||
| 117 | 118 | onRevertUserMessage, |
| 118 | 119 | isRevertingCheckpoint, |
| 119 | 120 | onImageExpand, |
| 121 | + activeThreadEnvironmentId, | |
| 120 | 122 | markdownCwd, |
| 121 | 123 | resolvedTheme, |
| 122 | 124 | timestampFormat, |
| @@ -531,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 408 unmodified lines | ||
| 531 | 533 | <div className="min-w-0 px-1 py-0.5"> |
| 532 | 534 | <ProposedPlanCard |
| 533 | 535 | planMarkdown={row.proposedPlan.planMarkdown} |
| 536 | + environmentId={activeThreadEnvironmentId} | |
| 534 | 537 | cwd={markdownCwd} |
| 535 | 538 | workspaceRoot={workspaceRoot} |
| 536 | 539 | /> |
| @@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = { · 18 unmodified lines | ||
| 19 | 19 | height: 1_100, |
| 20 | 20 | }; |
| 21 | 21 | const MARKDOWN_CWD = "/repo/project"; |
| 22 | +const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; | |
| 22 | 23 | |
| 23 | 24 | interface RowMeasurement { |
| 24 | 25 | actualHeightPx: number; |
| @@ -31,7 +32,10 @@ interface RowMeasurement { · 6 unmodified lines | ||
| 31 | 32 | interface VirtualizationScenario { |
| 32 | 33 | name: string; |
| 33 | 34 | targetRowId: string; |
| 34 | − props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">; | |
| 35 | + props: Omit< | |
| 36 | + ComponentProps<typeof MessagesTimeline>, | |
| 37 | + "scrollContainer" | "activeThreadEnvironmentId" | |
| 38 | + >; | |
| 35 | 39 | maxEstimateDeltaPx: number; |
| 36 | 40 | } |
| 37 | 41 | |
| @@ -48,7 +52,10 @@ interface VirtualizerSnapshot { · 10 unmodified lines | ||
| 48 | 52 | } |
| 49 | 53 | |
| 50 | 54 | function MessagesTimelineBrowserHarness( |
| 51 | − props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">, | |
| 55 | + props: Omit< | |
| 56 | + ComponentProps<typeof MessagesTimeline>, | |
| 57 | + "scrollContainer" | "activeThreadEnvironmentId" | |
| 58 | + >, | |
| 52 | 59 | ) { |
| 53 | 60 | const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null); |
| 54 | 61 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>( |
| @@ -73,6 +80,7 @@ function MessagesTimelineBrowserHarness( · 18 unmodified lines | ||
| 73 | 80 | > |
| 74 | 81 | <MessagesTimeline |
| 75 | 82 | {...props} |
| 83 | + activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} | |
| 76 | 84 | scrollContainer={scrollContainer} |
| 77 | 85 | expandedWorkGroups={expandedWorkGroups} |
| 78 | 86 | onToggleWorkGroup={handleToggleWorkGroup} |
| @@ -143,7 +151,7 @@ function createBaseTimelineProps(input: { · 64 unmodified lines | ||
| 143 | 151 | completionDividerBeforeEntryId?: string | null; |
| 144 | 152 | turnDiffSummaryByAssistantMessageId?: Map<MessageId, TurnDiffSummary>; |
| 145 | 153 | onVirtualizerSnapshot?: ComponentProps<typeof MessagesTimeline>["onVirtualizerSnapshot"]; |
| 146 | −}): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer"> { | |
| 154 | +}): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer" | "activeThreadEnvironmentId"> { | |
| 147 | 155 | return { |
| 148 | 156 | hasMessages: true, |
| 149 | 157 | isWorking: false, |
| @@ -481,7 +489,10 @@ async function waitForElement<T extends Element>( · 331 unmodified lines | ||
| 481 | 489 | |
| 482 | 490 | async function measureTimelineRow(input: { |
| 483 | 491 | host: HTMLElement; |
| 484 | − props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">; | |
| 492 | + props: Omit< | |
| 493 | + ComponentProps<typeof MessagesTimeline>, | |
| 494 | + "scrollContainer" | "activeThreadEnvironmentId" | |
| 495 | + >; | |
| 485 | 496 | targetRowId: string; |
| 486 | 497 | }): Promise<RowMeasurement> { |
| 487 | 498 | const scrollContainer = await waitForElement( |
| @@ -550,7 +561,10 @@ async function measureTimelineRow(input: { · 62 unmodified lines | ||
| 550 | 561 | } |
| 551 | 562 | |
| 552 | 563 | async function mountMessagesTimeline(input: { |
| 553 | − props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">; | |
| 564 | + props: Omit< | |
| 565 | + ComponentProps<typeof MessagesTimeline>, | |
| 566 | + "scrollContainer" | "activeThreadEnvironmentId" | |
| 567 | + >; | |
| 554 | 568 | viewport?: { width: number; height: number }; |
| 555 | 569 | }) { |
| 556 | 570 | const viewport = input.viewport ?? DEFAULT_VIEWPORT; |
| @@ -576,7 +590,10 @@ async function mountMessagesTimeline(input: { · 19 unmodified lines | ||
| 576 | 590 | return { |
| 577 | 591 | host, |
| 578 | 592 | rerender: async ( |
| 579 | − nextProps: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">, | |
| 593 | + nextProps: Omit< | |
| 594 | + ComponentProps<typeof MessagesTimeline>, | |
| 595 | + "scrollContainer" | "activeThreadEnvironmentId" | |
| 596 | + >, | |
| 580 | 597 | ) => { |
| 581 | 598 | await screen.rerender(<MessagesTimelineBrowserHarness {...nextProps} />); |
| 582 | 599 | await waitForLayout(); |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | Zed, |
| 19 | 19 | } from "../Icons"; |
| 20 | 20 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 21 | −import { readNativeApi } from "~/nativeApi"; | |
| 21 | +import { readLocalApi } from "~/localApi"; | |
| 22 | 22 | |
| 23 | 23 | const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => { |
| 24 | 24 | const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ |
| @@ -93,7 +93,7 @@ export const OpenInPicker = memo(function OpenInPicker({ · 68 unmodified lines | ||
| 93 | 93 | |
| 94 | 94 | const openInEditor = useCallback( |
| 95 | 95 | (editorId: EditorId | null) => { |
| 96 | − const api = readNativeApi(); | |
| 96 | + const api = readLocalApi(); | |
| 97 | 97 | if (!api || !openInCwd) return; |
| 98 | 98 | const editor = editorId ?? preferredEditor; |
| 99 | 99 | if (!editor) return; |
| @@ -110,7 +110,7 @@ export const OpenInPicker = memo(function OpenInPicker({ · 10 unmodified lines | ||
| 110 | 110 | |
| 111 | 111 | useEffect(() => { |
| 112 | 112 | const handler = (e: globalThis.KeyboardEvent) => { |
| 113 | − const api = readNativeApi(); | |
| 113 | + const api = readLocalApi(); | |
| 114 | 114 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; |
| 115 | 115 | if (!api || !openInCwd) return; |
| 116 | 116 | if (!preferredEditor) return; |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { memo, useState, useId } from "react"; |
| 2 | +import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 3 | import { |
| 3 | 4 | buildCollapsedProposedPlanPreviewMarkdown, |
| 4 | 5 | buildProposedPlanMarkdownFilename, |
| @@ -24,15 +25,17 @@ import { · 19 unmodified lines | ||
| 24 | 25 | DialogTitle, |
| 25 | 26 | } from "../ui/dialog"; |
| 26 | 27 | import { toastManager } from "../ui/toast"; |
| 27 | −import { readNativeApi } from "~/nativeApi"; | |
| 28 | +import { readEnvironmentApi } from "~/environmentApi"; | |
| 28 | 29 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 29 | 30 | |
| 30 | 31 | export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 31 | 32 | planMarkdown, |
| 33 | + environmentId, | |
| 32 | 34 | cwd, |
| 33 | 35 | workspaceRoot, |
| 34 | 36 | }: { |
| 35 | 37 | planMarkdown: string; |
| 38 | + environmentId: EnvironmentId; | |
| 36 | 39 | cwd: string | undefined; |
| 37 | 40 | workspaceRoot: string | undefined; |
| 38 | 41 | }) { |
| @@ -82,7 +85,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ · 43 unmodified lines | ||
| 82 | 85 | }; |
| 83 | 86 | |
| 84 | 87 | const handleSaveToWorkspace = () => { |
| 85 | − const api = readNativeApi(); | |
| 88 | + const api = readEnvironmentApi(environmentId); | |
| 86 | 89 | const relativePath = savePath.trim(); |
| 87 | 90 | if (!api || !workspaceRoot) { |
| 88 | 91 | return; |
| @@ -6,10 +6,11 @@ import { · 5 unmodified lines | ||
| 6 | 6 | CodexModelOptions, |
| 7 | 7 | DEFAULT_MODEL_BY_PROVIDER, |
| 8 | 8 | DEFAULT_SERVER_SETTINGS, |
| 9 | − ProjectId, | |
| 9 | + EnvironmentId, | |
| 10 | 10 | type ServerProvider, |
| 11 | 11 | ThreadId, |
| 12 | 12 | } from "@t3tools/contracts"; |
| 13 | +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | |
| 13 | 14 | import { page } from "vitest/browser"; |
| 14 | 15 | import { useCallback } from "react"; |
| 15 | 16 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| @@ -27,7 +28,13 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; · 11 unmodified lines | ||
| 27 | 28 | |
| 28 | 29 | // ── Claude TraitsPicker tests ───────────────────────────────────────── |
| 29 | 30 | |
| 31 | +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); | |
| 30 | 32 | const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); |
| 33 | +const CLAUDE_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CLAUDE_THREAD_ID); | |
| 34 | +const CLAUDE_THREAD_KEY = scopedThreadKey(CLAUDE_THREAD_REF); | |
| 35 | +const CODEX_THREAD_ID = ThreadId.makeUnsafe("thread-codex-traits"); | |
| 36 | +const CODEX_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CODEX_THREAD_ID); | |
| 37 | +const CODEX_THREAD_KEY = scopedThreadKey(CODEX_THREAD_REF); | |
| 31 | 38 | const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 32 | 39 | { |
| 33 | 40 | provider: "codex", |
| @@ -120,10 +127,10 @@ function ClaudeTraitsPickerHarness(props: { · 86 unmodified lines | ||
| 120 | 127 | fallbackModelSelection: ModelSelection | null; |
| 121 | 128 | triggerVariant?: "ghost" | "outline"; |
| 122 | 129 | }) { |
| 123 | − const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt; | |
| 130 | + const prompt = useComposerThreadDraft(CLAUDE_THREAD_REF).prompt; | |
| 124 | 131 | const setPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 125 | 132 | const { modelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 126 | − threadId: CLAUDE_THREAD_ID, | |
| 133 | + threadRef: CLAUDE_THREAD_REF, | |
| 127 | 134 | providers: TEST_PROVIDERS, |
| 128 | 135 | selectedProvider: "claudeAgent", |
| 129 | 136 | threadModelSelection: props.fallbackModelSelection, |
| @@ -135,7 +142,7 @@ function ClaudeTraitsPickerHarness(props: { · 5 unmodified lines | ||
| 135 | 142 | }); |
| 136 | 143 | const handlePromptChange = useCallback( |
| 137 | 144 | (nextPrompt: string) => { |
| 138 | − setPrompt(CLAUDE_THREAD_ID, nextPrompt); | |
| 145 | + setPrompt(CLAUDE_THREAD_REF, nextPrompt); | |
| 139 | 146 | }, |
| 140 | 147 | [setPrompt], |
| 141 | 148 | ); |
| @@ -144,7 +151,7 @@ function ClaudeTraitsPickerHarness(props: { · 2 unmodified lines | ||
| 144 | 151 | <TraitsPicker |
| 145 | 152 | provider="claudeAgent" |
| 146 | 153 | models={TEST_PROVIDERS[1]!.models} |
| 147 | − threadId={CLAUDE_THREAD_ID} | |
| 154 | + threadRef={CLAUDE_THREAD_REF} | |
| 148 | 155 | model={selectedModel ?? props.model} |
| 149 | 156 | prompt={prompt} |
| 150 | 157 | modelOptions={modelOptions?.claudeAgent} |
| @@ -168,8 +175,8 @@ async function mountClaudePicker(props?: { · 17 unmodified lines | ||
| 168 | 175 | }) { |
| 169 | 176 | const model = props?.model ?? "claude-opus-4-6"; |
| 170 | 177 | const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined; |
| 171 | − const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = { | |
| 172 | − [CLAUDE_THREAD_ID]: { | |
| 178 | + const draftsByThreadKey: Record<string, ComposerThreadDraftState> = { | |
| 179 | + [CLAUDE_THREAD_KEY]: { | |
| 173 | 180 | prompt: props?.prompt ?? "", |
| 174 | 181 | images: [], |
| 175 | 182 | nonPersistedImageIds: [], |
| @@ -192,9 +199,9 @@ async function mountClaudePicker(props?: { · 16 unmodified lines | ||
| 192 | 199 | }, |
| 193 | 200 | }; |
| 194 | 201 | useComposerDraftStore.setState({ |
| 195 | − draftsByThreadId, | |
| 196 | − draftThreadsByThreadId: {}, | |
| 197 | − projectDraftThreadIdByProjectId: {}, | |
| 202 | + draftsByThreadKey, | |
| 203 | + draftThreadsByThreadKey: {}, | |
| 204 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 198 | 205 | }); |
| 199 | 206 | const host = document.createElement("div"); |
| 200 | 207 | document.body.append(host); |
| @@ -230,9 +237,9 @@ describe("TraitsPicker (Claude)", () => { · 29 unmodified lines | ||
| 230 | 237 | afterEach(() => { |
| 231 | 238 | document.body.innerHTML = ""; |
| 232 | 239 | useComposerDraftStore.setState({ |
| 233 | − draftsByThreadId: {}, | |
| 234 | − draftThreadsByThreadId: {}, | |
| 235 | − projectDraftThreadIdByProjectId: {}, | |
| 240 | + draftsByThreadKey: {}, | |
| 241 | + draftThreadsByThreadKey: {}, | |
| 242 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 236 | 243 | stickyModelSelectionByProvider: {}, |
| 237 | 244 | }); |
| 238 | 245 | }); |
| @@ -369,10 +376,9 @@ describe("TraitsPicker (Claude)", () => { · 130 unmodified lines | ||
| 369 | 376 | // ── Codex TraitsPicker tests ────────────────────────────────────────── |
| 370 | 377 | |
| 371 | 378 | async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) { |
| 372 | − const threadId = ThreadId.makeUnsafe("thread-codex-traits"); | |
| 373 | 379 | const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex; |
| 374 | − const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = { | |
| 375 | − [threadId]: { | |
| 380 | + const draftsByThreadKey: Record<string, ComposerThreadDraftState> = { | |
| 381 | + [CODEX_THREAD_KEY]: { | |
| 376 | 382 | prompt: "", |
| 377 | 383 | images: [], |
| 378 | 384 | nonPersistedImageIds: [], |
| @@ -392,10 +398,10 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt · 13 unmodified lines | ||
| 392 | 398 | }; |
| 393 | 399 | |
| 394 | 400 | useComposerDraftStore.setState({ |
| 395 | − draftsByThreadId, | |
| 396 | − draftThreadsByThreadId: {}, | |
| 397 | − projectDraftThreadIdByProjectId: { | |
| 398 | − [ProjectId.makeUnsafe("project-codex-traits")]: threadId, | |
| 401 | + draftsByThreadKey, | |
| 402 | + draftThreadsByThreadKey: {}, | |
| 403 | + logicalProjectDraftThreadKeyByLogicalProjectKey: { | |
| 404 | + "environment-local:project-codex-traits": CODEX_THREAD_KEY, | |
| 399 | 405 | }, |
| 400 | 406 | }); |
| 401 | 407 | const host = document.createElement("div"); |
| @@ -404,7 +410,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt · 2 unmodified lines | ||
| 404 | 410 | <TraitsPicker |
| 405 | 411 | provider="codex" |
| 406 | 412 | models={TEST_PROVIDERS[0]!.models} |
| 407 | − threadId={threadId} | |
| 413 | + threadRef={CODEX_THREAD_REF} | |
| 408 | 414 | model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex} |
| 409 | 415 | prompt="" |
| 410 | 416 | modelOptions={props.options} |
| @@ -429,9 +435,9 @@ describe("TraitsPicker (Codex)", () => { · 18 unmodified lines | ||
| 429 | 435 | document.body.innerHTML = ""; |
| 430 | 436 | localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); |
| 431 | 437 | useComposerDraftStore.setState({ |
| 432 | − draftsByThreadId: {}, | |
| 433 | − draftThreadsByThreadId: {}, | |
| 434 | − projectDraftThreadIdByProjectId: {}, | |
| 438 | + draftsByThreadKey: {}, | |
| 439 | + draftThreadsByThreadKey: {}, | |
| 440 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 435 | 441 | stickyModelSelectionByProvider: {}, |
| 436 | 442 | }); |
| 437 | 443 | }); |
| @@ -3,8 +3,8 @@ import { · 2 unmodified lines | ||
| 3 | 3 | type CodexModelOptions, |
| 4 | 4 | type ProviderKind, |
| 5 | 5 | type ProviderModelOptions, |
| 6 | + type ScopedThreadRef, | |
| 6 | 7 | type ServerProviderModel, |
| 7 | − type ThreadId, | |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { |
| 10 | 10 | applyClaudePromptEffortPrefix, |
| @@ -28,18 +28,19 @@ import { · 17 unmodified lines | ||
| 28 | 28 | MenuSeparator as MenuDivider, |
| 29 | 29 | MenuTrigger, |
| 30 | 30 | } from "../ui/menu"; |
| 31 | −import { useComposerDraftStore } from "../../composerDraftStore"; | |
| 31 | +import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; | |
| 32 | 32 | import { getProviderModelCapabilities } from "../../providerModels"; |
| 33 | 33 | import { cn } from "~/lib/utils"; |
| 34 | 34 | |
| 35 | 35 | type ProviderOptions = ProviderModelOptions[ProviderKind]; |
| 36 | 36 | type TraitsPersistence = |
| 37 | 37 | | { |
| 38 | − threadId: ThreadId; | |
| 38 | + threadRef?: ScopedThreadRef; | |
| 39 | + draftId?: DraftId; | |
| 39 | 40 | onModelOptionsChange?: never; |
| 40 | 41 | } |
| 41 | 42 | | { |
| 42 | − threadId?: undefined; | |
| 43 | + threadRef?: undefined; | |
| 43 | 44 | onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void; |
| 44 | 45 | }; |
| 45 | 46 | |
| @@ -167,7 +168,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 121 unmodified lines | ||
| 167 | 168 | persistence.onModelOptionsChange(nextOptions); |
| 168 | 169 | return; |
| 169 | 170 | } |
| 170 | − setProviderModelOptions(persistence.threadId, provider, nextOptions, { persistSticky: true }); | |
| 171 | + const threadTarget = persistence.threadRef ?? persistence.draftId; | |
| 172 | + if (!threadTarget) { | |
| 173 | + return; | |
| 174 | + } | |
| 175 | + setProviderModelOptions(threadTarget, provider, nextOptions, { | |
| 176 | + persistSticky: true, | |
| 177 | + }); | |
| 171 | 178 | }, |
| 172 | 179 | [persistence, provider, setProviderModelOptions], |
| 173 | 180 | ); |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | import type { ServerProviderModel } from "@t3tools/contracts"; |
| 3 | −import { getComposerProviderState } from "./composerProviderRegistry"; | |
| 3 | +import { | |
| 4 | + getComposerProviderState, | |
| 5 | + renderProviderTraitsMenuContent, | |
| 6 | + renderProviderTraitsPicker, | |
| 7 | +} from "./composerProviderRegistry"; | |
| 4 | 8 | |
| 5 | 9 | const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [ |
| 6 | 10 | { |
| @@ -417,3 +421,31 @@ describe("getComposerProviderState", () => { · 410 unmodified lines | ||
| 417 | 421 | expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); |
| 418 | 422 | }); |
| 419 | 423 | }); |
| 424 | + | |
| 425 | +describe("provider traits render guards", () => { | |
| 426 | + it("returns null for codex traits picker when no thread target is provided", () => { | |
| 427 | + const content = renderProviderTraitsPicker({ | |
| 428 | + provider: "codex", | |
| 429 | + model: "gpt-5.4", | |
| 430 | + models: CODEX_MODELS, | |
| 431 | + modelOptions: undefined, | |
| 432 | + prompt: "", | |
| 433 | + onPromptChange: () => {}, | |
| 434 | + }); | |
| 435 | + | |
| 436 | + expect(content).toBeNull(); | |
| 437 | + }); | |
| 438 | + | |
| 439 | + it("returns null for claude traits menu content when no thread target is provided", () => { | |
| 440 | + const content = renderProviderTraitsMenuContent({ | |
| 441 | + provider: "claudeAgent", | |
| 442 | + model: "claude-sonnet-4-6", | |
| 443 | + models: CLAUDE_MODELS, | |
| 444 | + modelOptions: undefined, | |
| 445 | + prompt: "", | |
| 446 | + onPromptChange: () => {}, | |
| 447 | + }); | |
| 448 | + | |
| 449 | + expect(content).toBeNull(); | |
| 450 | + }); | |
| 451 | +}); | |
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | type ProviderKind, |
| 3 | 3 | type ProviderModelOptions, |
| 4 | + type ScopedThreadRef, | |
| 4 | 5 | type ServerProviderModel, |
| 5 | − type ThreadId, | |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; |
| 8 | 8 | import type { ReactNode } from "react"; |
| 9 | +import type { DraftId } from "../../composerDraftStore"; | |
| 9 | 10 | import { getProviderModelCapabilities } from "../../providerModels"; |
| 10 | 11 | import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; |
| 11 | 12 | import { |
| @@ -33,7 +34,8 @@ export type ComposerProviderState = { · 21 unmodified lines | ||
| 33 | 34 | type ProviderRegistryEntry = { |
| 34 | 35 | getState: (input: ComposerProviderStateInput) => ComposerProviderState; |
| 35 | 36 | renderTraitsMenuContent: (input: { |
| 36 | − threadId: ThreadId; | |
| 37 | + threadRef?: ScopedThreadRef; | |
| 38 | + draftId?: DraftId; | |
| 37 | 39 | model: string; |
| 38 | 40 | models: ReadonlyArray<ServerProviderModel>; |
| 39 | 41 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| @@ -41,7 +43,8 @@ type ProviderRegistryEntry = { · 1 unmodified line | ||
| 41 | 43 | onPromptChange: (prompt: string) => void; |
| 42 | 44 | }) => ReactNode; |
| 43 | 45 | renderTraitsPicker: (input: { |
| 44 | − threadId: ThreadId; | |
| 46 | + threadRef?: ScopedThreadRef; | |
| 47 | + draftId?: DraftId; | |
| 45 | 48 | model: string; |
| 46 | 49 | models: ReadonlyArray<ServerProviderModel>; |
| 47 | 50 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| @@ -50,6 +53,13 @@ type ProviderRegistryEntry = { · 2 unmodified lines | ||
| 50 | 53 | }) => ReactNode; |
| 51 | 54 | }; |
| 52 | 55 | |
| 56 | +function hasComposerTraitsTarget(input: { | |
| 57 | + threadRef: ScopedThreadRef | undefined; | |
| 58 | + draftId: DraftId | undefined; | |
| 59 | +}): boolean { | |
| 60 | + return input.threadRef !== undefined || input.draftId !== undefined; | |
| 61 | +} | |
| 62 | + | |
| 53 | 63 | function getProviderStateFromCapabilities( |
| 54 | 64 | input: ComposerProviderStateInput, |
| 55 | 65 | ): ComposerProviderState { |
| @@ -94,66 +104,92 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 38 unmodified lines | ||
| 94 | 104 | codex: { |
| 95 | 105 | getState: (input) => getProviderStateFromCapabilities(input), |
| 96 | 106 | renderTraitsMenuContent: ({ |
| 97 | − threadId, | |
| 107 | + threadRef, | |
| 108 | + draftId, | |
| 109 | + model, | |
| 110 | + models, | |
| 111 | + modelOptions, | |
| 112 | + prompt, | |
| 113 | + onPromptChange, | |
| 114 | + }) => | |
| 115 | + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( | |
| 116 | + <TraitsMenuContent | |
| 117 | + provider="codex" | |
| 118 | + models={models} | |
| 119 | + {...(threadRef ? { threadRef } : {})} | |
| 120 | + {...(draftId ? { draftId } : {})} | |
| 121 | + model={model} | |
| 122 | + modelOptions={modelOptions} | |
| 123 | + prompt={prompt} | |
| 124 | + onPromptChange={onPromptChange} | |
| 125 | + /> | |
| 126 | + ), | |
| 127 | + renderTraitsPicker: ({ | |
| 128 | + threadRef, | |
| 129 | + draftId, | |
| 98 | 130 | model, |
| 99 | 131 | models, |
| 100 | 132 | modelOptions, |
| 101 | 133 | prompt, |
| 102 | 134 | onPromptChange, |
| 103 | − }) => ( | |
| 104 | − <TraitsMenuContent | |
| 105 | − provider="codex" | |
| 106 | − models={models} | |
| 107 | − threadId={threadId} | |
| 108 | − model={model} | |
| 109 | − modelOptions={modelOptions} | |
| 110 | − prompt={prompt} | |
| 111 | − onPromptChange={onPromptChange} | |
| 112 | − /> | |
| 113 | − ), | |
| 114 | − renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( | |
| 115 | − <TraitsPicker | |
| 116 | − provider="codex" | |
| 117 | − models={models} | |
| 118 | − threadId={threadId} | |
| 119 | − model={model} | |
| 120 | − modelOptions={modelOptions} | |
| 121 | − prompt={prompt} | |
| 122 | − onPromptChange={onPromptChange} | |
| 123 | − /> | |
| 124 | − ), | |
| 135 | + }) => | |
| 136 | + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( | |
| 137 | + <TraitsPicker | |
| 138 | + provider="codex" | |
| 139 | + models={models} | |
| 140 | + {...(threadRef ? { threadRef } : {})} | |
| 141 | + {...(draftId ? { draftId } : {})} | |
| 142 | + model={model} | |
| 143 | + modelOptions={modelOptions} | |
| 144 | + prompt={prompt} | |
| 145 | + onPromptChange={onPromptChange} | |
| 146 | + /> | |
| 147 | + ), | |
| 125 | 148 | }, |
| 126 | 149 | claudeAgent: { |
| 127 | 150 | getState: (input) => getProviderStateFromCapabilities(input), |
| 128 | 151 | renderTraitsMenuContent: ({ |
| 129 | − threadId, | |
| 152 | + threadRef, | |
| 153 | + draftId, | |
| 154 | + model, | |
| 155 | + models, | |
| 156 | + modelOptions, | |
| 157 | + prompt, | |
| 158 | + onPromptChange, | |
| 159 | + }) => | |
| 160 | + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( | |
| 161 | + <TraitsMenuContent | |
| 162 | + provider="claudeAgent" | |
| 163 | + models={models} | |
| 164 | + {...(threadRef ? { threadRef } : {})} | |
| 165 | + {...(draftId ? { draftId } : {})} | |
| 166 | + model={model} | |
| 167 | + modelOptions={modelOptions} | |
| 168 | + prompt={prompt} | |
| 169 | + onPromptChange={onPromptChange} | |
| 170 | + /> | |
| 171 | + ), | |
| 172 | + renderTraitsPicker: ({ | |
| 173 | + threadRef, | |
| 174 | + draftId, | |
| 130 | 175 | model, |
| 131 | 176 | models, |
| 132 | 177 | modelOptions, |
| 133 | 178 | prompt, |
| 134 | 179 | onPromptChange, |
| 135 | − }) => ( | |
| 136 | − <TraitsMenuContent | |
| 137 | − provider="claudeAgent" | |
| 138 | − models={models} | |
| 139 | − threadId={threadId} | |
| 140 | − model={model} | |
| 141 | − modelOptions={modelOptions} | |
| 142 | − prompt={prompt} | |
| 143 | − onPromptChange={onPromptChange} | |
| 144 | − /> | |
| 145 | − ), | |
| 146 | − renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( | |
| 147 | − <TraitsPicker | |
| 148 | − provider="claudeAgent" | |
| 149 | − models={models} | |
| 150 | − threadId={threadId} | |
| 151 | − model={model} | |
| 152 | − modelOptions={modelOptions} | |
| 153 | − prompt={prompt} | |
| 154 | − onPromptChange={onPromptChange} | |
| 155 | − /> | |
| 156 | − ), | |
| 180 | + }) => | |
| 181 | + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( | |
| 182 | + <TraitsPicker | |
| 183 | + provider="claudeAgent" | |
| 184 | + models={models} | |
| 185 | + {...(threadRef ? { threadRef } : {})} | |
| 186 | + {...(draftId ? { draftId } : {})} | |
| 187 | + model={model} | |
| 188 | + modelOptions={modelOptions} | |
| 189 | + prompt={prompt} | |
| 190 | + onPromptChange={onPromptChange} | |
| 191 | + /> | |
| 192 | + ), | |
| 157 | 193 | }, |
| 158 | 194 | }; |
| 159 | 195 | |
| @@ -163,7 +199,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com · 3 unmodified lines | ||
| 163 | 199 | |
| 164 | 200 | export function renderProviderTraitsMenuContent(input: { |
| 165 | 201 | provider: ProviderKind; |
| 166 | − threadId: ThreadId; | |
| 202 | + threadRef?: ScopedThreadRef; | |
| 203 | + draftId?: DraftId; | |
| 167 | 204 | model: string; |
| 168 | 205 | models: ReadonlyArray<ServerProviderModel>; |
| 169 | 206 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| @@ -171,7 +208,8 @@ export function renderProviderTraitsMenuContent(input: { · 1 unmodified line | ||
| 171 | 208 | onPromptChange: (prompt: string) => void; |
| 172 | 209 | }): ReactNode { |
| 173 | 210 | return composerProviderRegistry[input.provider].renderTraitsMenuContent({ |
| 174 | − threadId: input.threadId, | |
| 211 | + ...(input.threadRef ? { threadRef: input.threadRef } : {}), | |
| 212 | + ...(input.draftId ? { draftId: input.draftId } : {}), | |
| 175 | 213 | model: input.model, |
| 176 | 214 | models: input.models, |
| 177 | 215 | modelOptions: input.modelOptions, |
| @@ -182,7 +220,8 @@ export function renderProviderTraitsMenuContent(input: { · 4 unmodified lines | ||
| 182 | 220 | |
| 183 | 221 | export function renderProviderTraitsPicker(input: { |
| 184 | 222 | provider: ProviderKind; |
| 185 | − threadId: ThreadId; | |
| 223 | + threadRef?: ScopedThreadRef; | |
| 224 | + draftId?: DraftId; | |
| 186 | 225 | model: string; |
| 187 | 226 | models: ReadonlyArray<ServerProviderModel>; |
| 188 | 227 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| @@ -190,7 +229,8 @@ export function renderProviderTraitsPicker(input: { · 1 unmodified line | ||
| 190 | 229 | onPromptChange: (prompt: string) => void; |
| 191 | 230 | }): ReactNode { |
| 192 | 231 | return composerProviderRegistry[input.provider].renderTraitsPicker({ |
| 193 | − threadId: input.threadId, | |
| 232 | + ...(input.threadRef ? { threadRef: input.threadRef } : {}), | |
| 233 | + ...(input.draftId ? { draftId: input.draftId } : {}), | |
| 194 | 234 | model: input.model, |
| 195 | 235 | models: input.models, |
| 196 | 236 | modelOptions: input.modelOptions, |
| @@ -1,17 +1,29 @@ | ||
| 1 | 1 | import "../../index.css"; |
| 2 | 2 | |
| 3 | −import { DEFAULT_SERVER_SETTINGS, type NativeApi, type ServerConfig } from "@t3tools/contracts"; | |
| 3 | +import { | |
| 4 | + DEFAULT_SERVER_SETTINGS, | |
| 5 | + EnvironmentId, | |
| 6 | + type LocalApi, | |
| 7 | + type ServerConfig, | |
| 8 | +} from "@t3tools/contracts"; | |
| 4 | 9 | import { page } from "vitest/browser"; |
| 5 | 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 6 | 11 | import { render } from "vitest-browser-react"; |
| 7 | 12 | |
| 8 | −import { __resetNativeApiForTests } from "../../nativeApi"; | |
| 13 | +import { __resetLocalApiForTests } from "../../localApi"; | |
| 9 | 14 | import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; |
| 10 | 15 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; |
| 11 | 16 | import { GeneralSettingsPanel } from "./SettingsPanels"; |
| 12 | 17 | |
| 13 | 18 | function createBaseServerConfig(): ServerConfig { |
| 14 | 19 | return { |
| 20 | + environment: { | |
| 21 | + environmentId: EnvironmentId.makeUnsafe("environment-local"), | |
| 22 | + label: "Local environment", | |
| 23 | + platform: { os: "darwin" as const, arch: "arm64" as const }, | |
| 24 | + serverVersion: "0.0.0-test", | |
| 25 | + capabilities: { repositoryIdentity: true }, | |
| 26 | + }, | |
| 15 | 27 | cwd: "/repo/project", |
| 16 | 28 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 17 | 29 | keybindings: [], |
| @@ -32,14 +44,14 @@ function createBaseServerConfig(): ServerConfig { · 14 unmodified lines | ||
| 32 | 44 | describe("GeneralSettingsPanel observability", () => { |
| 33 | 45 | beforeEach(async () => { |
| 34 | 46 | resetServerStateForTests(); |
| 35 | − await __resetNativeApiForTests(); | |
| 47 | + await __resetLocalApiForTests(); | |
| 36 | 48 | localStorage.clear(); |
| 37 | 49 | document.body.innerHTML = ""; |
| 38 | 50 | }); |
| 39 | 51 | |
| 40 | 52 | afterEach(async () => { |
| 41 | 53 | resetServerStateForTests(); |
| 42 | − await __resetNativeApiForTests(); | |
| 54 | + await __resetLocalApiForTests(); | |
| 43 | 55 | document.body.innerHTML = ""; |
| 44 | 56 | }); |
| 45 | 57 | |
| @@ -68,12 +80,12 @@ describe("GeneralSettingsPanel observability", () => { · 22 unmodified lines | ||
| 68 | 80 | }); |
| 69 | 81 | |
| 70 | 82 | it("opens the logs folder in the preferred editor", async () => { |
| 71 | − const openInEditor = vi.fn<NativeApi["shell"]["openInEditor"]>().mockResolvedValue(undefined); | |
| 83 | + const openInEditor = vi.fn<LocalApi["shell"]["openInEditor"]>().mockResolvedValue(undefined); | |
| 72 | 84 | window.nativeApi = { |
| 73 | 85 | shell: { |
| 74 | 86 | openInEditor, |
| 75 | 87 | }, |
| 76 | − } as unknown as NativeApi; | |
| 88 | + } as unknown as LocalApi; | |
| 77 | 89 | |
| 78 | 90 | setServerConfigSnapshot(createBaseServerConfig()); |
| 79 | 91 | |
| @@ -13,11 +13,12 @@ import { useQueryClient } from "@tanstack/react-query"; · 12 unmodified lines | ||
| 13 | 13 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 14 | 14 | import { |
| 15 | 15 | PROVIDER_DISPLAY_NAMES, |
| 16 | + type ScopedThreadRef, | |
| 16 | 17 | type ProviderKind, |
| 17 | 18 | type ServerProvider, |
| 18 | 19 | type ServerProviderModel, |
| 19 | − ThreadId, | |
| 20 | 20 | } from "@t3tools/contracts"; |
| 21 | +import { scopeThreadRef } from "@t3tools/client-runtime"; | |
| 21 | 22 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 22 | 23 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 23 | 24 | import { Equal } from "effect"; |
| @@ -45,8 +46,12 @@ import { · 21 unmodified lines | ||
| 45 | 46 | getCustomModelOptionsByProvider, |
| 46 | 47 | resolveAppModelSelectionState, |
| 47 | 48 | } from "../../modelSelection"; |
| 48 | −import { ensureNativeApi, readNativeApi } from "../../nativeApi"; | |
| 49 | −import { useStore } from "../../store"; | |
| 49 | +import { ensureLocalApi, readLocalApi } from "../../localApi"; | |
| 50 | +import { | |
| 51 | + selectProjectsAcrossEnvironments, | |
| 52 | + selectThreadsAcrossEnvironments, | |
| 53 | + useStore, | |
| 54 | +} from "../../store"; | |
| 50 | 55 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; |
| 51 | 56 | import { cn } from "../../lib/utils"; |
| 52 | 57 | import { Button } from "../ui/button"; |
| @@ -496,8 +501,8 @@ export function useSettingsRestore(onRestored?: () => void) { · 443 unmodified lines | ||
| 496 | 501 | |
| 497 | 502 | const restoreDefaults = useCallback(async () => { |
| 498 | 503 | if (changedSettingLabels.length === 0) return; |
| 499 | − const api = readNativeApi(); | |
| 500 | − const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( | |
| 504 | + const api = readLocalApi(); | |
| 505 | + const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( | |
| 501 | 506 | ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( |
| 502 | 507 | "\n", |
| 503 | 508 | ), |
| @@ -554,7 +559,7 @@ export function GeneralSettingsPanel() { · 50 unmodified lines | ||
| 554 | 559 | if (refreshingRef.current) return; |
| 555 | 560 | refreshingRef.current = true; |
| 556 | 561 | setIsRefreshingProviders(true); |
| 557 | − void ensureNativeApi() | |
| 562 | + void ensureLocalApi() | |
| 558 | 563 | .server.refreshProviders() |
| 559 | 564 | .catch((error: unknown) => { |
| 560 | 565 | console.warn("Failed to refresh providers", error); |
| @@ -614,7 +619,7 @@ export function GeneralSettingsPanel() { · 53 unmodified lines | ||
| 614 | 619 | return; |
| 615 | 620 | } |
| 616 | 621 | |
| 617 | − void ensureNativeApi() | |
| 622 | + void ensureLocalApi() | |
| 618 | 623 | .shell.openInEditor(path, editor) |
| 619 | 624 | .catch((error) => { |
| 620 | 625 | setOpenPathErrorByTarget((existing) => ({ |
| @@ -1479,20 +1484,10 @@ export function GeneralSettingsPanel() { · 858 unmodified lines | ||
| 1479 | 1484 | } |
| 1480 | 1485 | |
| 1481 | 1486 | export function ArchivedThreadsPanel() { |
| 1482 | − const projectIds = useStore((store) => store.projectIds); | |
| 1483 | − const projectById = useStore((store) => store.projectById); | |
| 1484 | − const threadIds = useStore((store) => store.threadIds); | |
| 1485 | − const threadShellById = useStore((store) => store.threadShellById); | |
| 1487 | + const projects = useStore(selectProjectsAcrossEnvironments); | |
| 1488 | + const threads = useStore(selectThreadsAcrossEnvironments); | |
| 1486 | 1489 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); |
| 1487 | 1490 | const archivedGroups = useMemo(() => { |
| 1488 | − const projects = projectIds.flatMap((projectId) => { | |
| 1489 | − const project = projectById[projectId]; | |
| 1490 | − return project ? [project] : []; | |
| 1491 | − }); | |
| 1492 | − const threads = threadIds.flatMap((threadId) => { | |
| 1493 | − const thread = threadShellById[threadId]; | |
| 1494 | − return thread ? [thread] : []; | |
| 1495 | − }); | |
| 1496 | 1491 | return projects |
| 1497 | 1492 | .map((project) => ({ |
| 1498 | 1493 | project, |
| @@ -1505,11 +1500,11 @@ export function ArchivedThreadsPanel() { · 6 unmodified lines | ||
| 1505 | 1500 | }), |
| 1506 | 1501 | })) |
| 1507 | 1502 | .filter((group) => group.threads.length > 0); |
| 1508 | − }, [projectById, projectIds, threadIds, threadShellById]); | |
| 1503 | + }, [projects, threads]); | |
| 1509 | 1504 | |
| 1510 | 1505 | const handleArchivedThreadContextMenu = useCallback( |
| 1511 | − async (threadId: ThreadId, position: { x: number; y: number }) => { | |
| 1512 | − const api = readNativeApi(); | |
| 1506 | + async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { | |
| 1507 | + const api = readLocalApi(); | |
| 1513 | 1508 | if (!api) return; |
| 1514 | 1509 | const clicked = await api.contextMenu.show( |
| 1515 | 1510 | [ |
| @@ -1521,7 +1516,7 @@ export function ArchivedThreadsPanel() { · 5 unmodified lines | ||
| 1521 | 1516 | |
| 1522 | 1517 | if (clicked === "unarchive") { |
| 1523 | 1518 | try { |
| 1524 | − await unarchiveThread(threadId); | |
| 1519 | + await unarchiveThread(threadRef); | |
| 1525 | 1520 | } catch (error) { |
| 1526 | 1521 | toastManager.add({ |
| 1527 | 1522 | type: "error", |
| @@ -1533,7 +1528,7 @@ export function ArchivedThreadsPanel() { · 5 unmodified lines | ||
| 1533 | 1528 | } |
| 1534 | 1529 | |
| 1535 | 1530 | if (clicked === "delete") { |
| 1536 | − await confirmAndDeleteThread(threadId); | |
| 1531 | + await confirmAndDeleteThread(threadRef); | |
| 1537 | 1532 | } |
| 1538 | 1533 | }, |
| 1539 | 1534 | [confirmAndDeleteThread, unarchiveThread], |
| @@ -1566,10 +1561,13 @@ export function ArchivedThreadsPanel() { · 26 unmodified lines | ||
| 1566 | 1561 | className="flex items-center justify-between gap-3 border-t border-border px-4 py-3 first:border-t-0 sm:px-5" |
| 1567 | 1562 | onContextMenu={(event) => { |
| 1568 | 1563 | event.preventDefault(); |
| 1569 | − void handleArchivedThreadContextMenu(thread.id, { | |
| 1570 | − x: event.clientX, | |
| 1571 | − y: event.clientY, | |
| 1572 | − }); | |
| 1564 | + void handleArchivedThreadContextMenu( | |
| 1565 | + scopeThreadRef(thread.environmentId, thread.id), | |
| 1566 | + { | |
| 1567 | + x: event.clientX, | |
| 1568 | + y: event.clientY, | |
| 1569 | + }, | |
| 1570 | + ); | |
| 1573 | 1571 | }} |
| 1574 | 1572 | > |
| 1575 | 1573 | <div className="min-w-0 flex-1"> |
| @@ -1586,13 +1584,16 @@ export function ArchivedThreadsPanel() { · 10 unmodified lines | ||
| 1586 | 1584 | size="sm" |
| 1587 | 1585 | className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" |
| 1588 | 1586 | onClick={() => |
| 1589 | − void unarchiveThread(thread.id).catch((error) => { | |
| 1590 | − toastManager.add({ | |
| 1591 | − type: "error", | |
| 1592 | − title: "Failed to unarchive thread", | |
| 1593 | − description: error instanceof Error ? error.message : "An error occurred.", | |
| 1594 | − }); | |
| 1595 | − }) | |
| 1587 | + void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch( | |
| 1588 | + (error) => { | |
| 1589 | + toastManager.add({ | |
| 1590 | + type: "error", | |
| 1591 | + title: "Failed to unarchive thread", | |
| 1592 | + description: | |
| 1593 | + error instanceof Error ? error.message : "An error occurred.", | |
| 1594 | + }); | |
| 1595 | + }, | |
| 1596 | + ) | |
| 1596 | 1597 | } |
| 1597 | 1598 | > |
| 1598 | 1599 | <ArchiveX className="size-3.5" /> |
| @@ -1,5 +1,12 @@ | ||
| 1 | +import { | |
| 2 | + scopedProjectKey, | |
| 3 | + scopedThreadKey, | |
| 4 | + scopeProjectRef, | |
| 5 | + scopeThreadRef, | |
| 6 | +} from "@t3tools/client-runtime"; | |
| 1 | 7 | import * as Schema from "effect/Schema"; |
| 2 | 8 | import { |
| 9 | + EnvironmentId, | |
| 3 | 10 | ProjectId, |
| 4 | 11 | ThreadId, |
| 5 | 12 | type ModelSelection, |
| @@ -9,10 +16,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; · 3 unmodified lines | ||
| 9 | 16 | |
| 10 | 17 | import { |
| 11 | 18 | COMPOSER_DRAFT_STORAGE_KEY, |
| 12 | − clearPromotedDraftThread, | |
| 13 | − clearPromotedDraftThreads, | |
| 19 | + finalizePromotedDraftThreadByRef, | |
| 20 | + markPromotedDraftThread, | |
| 21 | + markPromotedDraftThreadByRef, | |
| 22 | + markPromotedDraftThreads, | |
| 23 | + markPromotedDraftThreadsByRef, | |
| 14 | 24 | type ComposerImageAttachment, |
| 15 | 25 | useComposerDraftStore, |
| 26 | + DraftId, | |
| 16 | 27 | } from "./composerDraftStore"; |
| 17 | 28 | import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; |
| 18 | 29 | import { |
| @@ -71,9 +82,9 @@ function makeTerminalContext(input: { · 52 unmodified lines | ||
| 71 | 82 | |
| 72 | 83 | function resetComposerDraftStore() { |
| 73 | 84 | useComposerDraftStore.setState({ |
| 74 | − draftsByThreadId: {}, | |
| 75 | − draftThreadsByThreadId: {}, | |
| 76 | − projectDraftThreadIdByProjectId: {}, | |
| 85 | + draftsByThreadKey: {}, | |
| 86 | + draftThreadsByThreadKey: {}, | |
| 87 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 77 | 88 | stickyModelSelectionByProvider: {}, |
| 78 | 89 | stickyActiveProvider: null, |
| 79 | 90 | }); |
| @@ -95,8 +106,32 @@ function providerModelOptions(options: ProviderModelOptions): ProviderModelOptio · 15 unmodified lines | ||
| 95 | 106 | return options; |
| 96 | 107 | } |
| 97 | 108 | |
| 109 | +const TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); | |
| 110 | +const OTHER_TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-remote"); | |
| 111 | +const LEGACY_TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("__legacy__"); | |
| 112 | + | |
| 113 | +function threadKeyFor( | |
| 114 | + threadId: ThreadId, | |
| 115 | + environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID, | |
| 116 | +): string { | |
| 117 | + if (environmentId === LEGACY_TEST_ENVIRONMENT_ID) { | |
| 118 | + return threadId; | |
| 119 | + } | |
| 120 | + return scopedThreadKey(scopeThreadRef(environmentId, threadId)); | |
| 121 | +} | |
| 122 | + | |
| 123 | +function draftFor(threadId: ThreadId, environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID) { | |
| 124 | + const store = useComposerDraftStore.getState().draftsByThreadKey; | |
| 125 | + return store[threadKeyFor(threadId, environmentId)] ?? store[threadId] ?? undefined; | |
| 126 | +} | |
| 127 | + | |
| 128 | +function draftByKey(key: string) { | |
| 129 | + return useComposerDraftStore.getState().draftsByThreadKey[key] ?? undefined; | |
| 130 | +} | |
| 131 | + | |
| 98 | 132 | describe("composerDraftStore addImages", () => { |
| 99 | 133 | const threadId = ThreadId.makeUnsafe("thread-dedupe"); |
| 134 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 100 | 135 | let originalRevokeObjectUrl: typeof URL.revokeObjectURL; |
| 101 | 136 | let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; |
| 102 | 137 | |
| @@ -129,9 +164,9 @@ describe("composerDraftStore addImages", () => { · 26 unmodified lines | ||
| 129 | 164 | lastModified: 12345, |
| 130 | 165 | }); |
| 131 | 166 | |
| 132 | − useComposerDraftStore.getState().addImages(threadId, [first, duplicate]); | |
| 167 | + useComposerDraftStore.getState().addImages(threadRef, [first, duplicate]); | |
| 133 | 168 | |
| 134 | − const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | |
| 169 | + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | |
| 135 | 170 | expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]); |
| 136 | 171 | expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); |
| 137 | 172 | }); |
| @@ -154,10 +189,10 @@ describe("composerDraftStore addImages", () => { · 16 unmodified lines | ||
| 154 | 189 | lastModified: 999, |
| 155 | 190 | }); |
| 156 | 191 | |
| 157 | − useComposerDraftStore.getState().addImage(threadId, first); | |
| 158 | − useComposerDraftStore.getState().addImage(threadId, duplicateLater); | |
| 192 | + useComposerDraftStore.getState().addImage(threadRef, first); | |
| 193 | + useComposerDraftStore.getState().addImage(threadRef, duplicateLater); | |
| 159 | 194 | |
| 160 | − const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | |
| 195 | + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | |
| 161 | 196 | expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]); |
| 162 | 197 | expect(revokeSpy).toHaveBeenCalledWith("blob:b"); |
| 163 | 198 | }); |
| @@ -172,9 +207,9 @@ describe("composerDraftStore addImages", () => { · 8 unmodified lines | ||
| 172 | 207 | previewUrl: "blob:shared", |
| 173 | 208 | }); |
| 174 | 209 | |
| 175 | − useComposerDraftStore.getState().addImages(threadId, [first, duplicateSameUrl]); | |
| 210 | + useComposerDraftStore.getState().addImages(threadRef, [first, duplicateSameUrl]); | |
| 176 | 211 | |
| 177 | − const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | |
| 212 | + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | |
| 178 | 213 | expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]); |
| 179 | 214 | expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); |
| 180 | 215 | }); |
| @@ -182,6 +217,7 @@ describe("composerDraftStore addImages", () => { · 1 unmodified line | ||
| 182 | 217 | |
| 183 | 218 | describe("composerDraftStore clearComposerContent", () => { |
| 184 | 219 | const threadId = ThreadId.makeUnsafe("thread-clear"); |
| 220 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 185 | 221 | let originalRevokeObjectUrl: typeof URL.revokeObjectURL; |
| 186 | 222 | let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; |
| 187 | 223 | |
| @@ -201,11 +237,11 @@ describe("composerDraftStore clearComposerContent", () => { · 13 unmodified lines | ||
| 201 | 237 | id: "img-optimistic", |
| 202 | 238 | previewUrl: "blob:optimistic", |
| 203 | 239 | }); |
| 204 | − useComposerDraftStore.getState().addImage(threadId, first); | |
| 240 | + useComposerDraftStore.getState().addImage(threadRef, first); | |
| 205 | 241 | |
| 206 | − useComposerDraftStore.getState().clearComposerContent(threadId); | |
| 242 | + useComposerDraftStore.getState().clearComposerContent(threadRef); | |
| 207 | 243 | |
| 208 | − const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | |
| 244 | + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | |
| 209 | 245 | expect(draft).toBeUndefined(); |
| 210 | 246 | expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); |
| 211 | 247 | }); |
| @@ -213,13 +249,14 @@ describe("composerDraftStore clearComposerContent", () => { · 1 unmodified line | ||
| 213 | 249 | |
| 214 | 250 | describe("composerDraftStore syncPersistedAttachments", () => { |
| 215 | 251 | const threadId = ThreadId.makeUnsafe("thread-sync-persisted"); |
| 252 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 216 | 253 | |
| 217 | 254 | beforeEach(() => { |
| 218 | 255 | removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); |
| 219 | 256 | useComposerDraftStore.setState({ |
| 220 | − draftsByThreadId: {}, | |
| 221 | − draftThreadsByThreadId: {}, | |
| 222 | − projectDraftThreadIdByProjectId: {}, | |
| 257 | + draftsByThreadKey: {}, | |
| 258 | + draftThreadsByThreadKey: {}, | |
| 259 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 223 | 260 | stickyModelSelectionByProvider: {}, |
| 224 | 261 | stickyActiveProvider: null, |
| 225 | 262 | }); |
| @@ -234,7 +271,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { · 8 unmodified lines | ||
| 234 | 271 | id: "img-persisted", |
| 235 | 272 | previewUrl: "blob:persisted", |
| 236 | 273 | }); |
| 237 | − useComposerDraftStore.getState().addImage(threadId, image); | |
| 274 | + useComposerDraftStore.getState().addImage(threadRef, image); | |
| 238 | 275 | setLocalStorageItem( |
| 239 | 276 | COMPOSER_DRAFT_STORAGE_KEY, |
| 240 | 277 | { |
| @@ -250,7 +287,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { · 9 unmodified lines | ||
| 250 | 287 | Schema.Unknown, |
| 251 | 288 | ); |
| 252 | 289 | |
| 253 | − useComposerDraftStore.getState().syncPersistedAttachments(threadId, [ | |
| 290 | + useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [ | |
| 254 | 291 | { |
| 255 | 292 | id: image.id, |
| 256 | 293 | name: image.name, |
| @@ -261,23 +298,20 @@ describe("composerDraftStore syncPersistedAttachments", () => { · 4 unmodified lines | ||
| 261 | 298 | ]); |
| 262 | 299 | await Promise.resolve(); |
| 263 | 300 | |
| 264 | − expect( | |
| 265 | − useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments, | |
| 266 | − ).toEqual([]); | |
| 267 | − expect( | |
| 268 | − useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedImageIds, | |
| 269 | − ).toEqual([image.id]); | |
| 301 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual([]); | |
| 302 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.nonPersistedImageIds).toEqual([image.id]); | |
| 270 | 303 | }); |
| 271 | 304 | }); |
| 272 | 305 | |
| 273 | 306 | describe("composerDraftStore terminal contexts", () => { |
| 274 | 307 | const threadId = ThreadId.makeUnsafe("thread-dedupe"); |
| 308 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 275 | 309 | |
| 276 | 310 | beforeEach(() => { |
| 277 | 311 | useComposerDraftStore.setState({ |
| 278 | − draftsByThreadId: {}, | |
| 279 | − draftThreadsByThreadId: {}, | |
| 280 | − projectDraftThreadIdByProjectId: {}, | |
| 312 | + draftsByThreadKey: {}, | |
| 313 | + draftThreadsByThreadKey: {}, | |
| 314 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 281 | 315 | stickyModelSelectionByProvider: {}, |
| 282 | 316 | stickyActiveProvider: null, |
| 283 | 317 | }); |
| @@ -287,20 +321,20 @@ describe("composerDraftStore terminal contexts", () => { · 3 unmodified lines | ||
| 287 | 321 | const first = makeTerminalContext({ id: "ctx-1" }); |
| 288 | 322 | const duplicate = makeTerminalContext({ id: "ctx-2" }); |
| 289 | 323 | |
| 290 | − useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]); | |
| 324 | + useComposerDraftStore.getState().addTerminalContexts(threadRef, [first, duplicate]); | |
| 291 | 325 | |
| 292 | − const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | |
| 326 | + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | |
| 293 | 327 | expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]); |
| 294 | 328 | }); |
| 295 | 329 | |
| 296 | 330 | it("clears terminal contexts when clearing composer content", () => { |
| 297 | 331 | useComposerDraftStore |
| 298 | 332 | .getState() |
| 299 | − .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-1" })); | |
| 333 | + .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-1" })); | |
| 300 | 334 | |
| 301 | − useComposerDraftStore.getState().clearComposerContent(threadId); | |
| 335 | + useComposerDraftStore.getState().clearComposerContent(threadRef); | |
| 302 | 336 | |
| 303 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | |
| 337 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); | |
| 304 | 338 | }); |
| 305 | 339 | |
| 306 | 340 | it("inserts terminal contexts at the requested inline prompt position", () => { |
| @@ -311,7 +345,7 @@ describe("composerDraftStore terminal contexts", () => { · 4 unmodified lines | ||
| 311 | 345 | useComposerDraftStore |
| 312 | 346 | .getState() |
| 313 | 347 | .insertTerminalContext( |
| 314 | − threadId, | |
| 348 | + threadRef, | |
| 315 | 349 | firstInsertion.prompt, |
| 316 | 350 | makeTerminalContext({ id: "ctx-1" }), |
| 317 | 351 | firstInsertion.contextIndex, |
| @@ -319,7 +353,7 @@ describe("composerDraftStore terminal contexts", () => { · 1 unmodified line | ||
| 319 | 353 | ).toBe(true); |
| 320 | 354 | expect( |
| 321 | 355 | useComposerDraftStore.getState().insertTerminalContext( |
| 322 | − threadId, | |
| 356 | + threadRef, | |
| 323 | 357 | secondInsertion.prompt, |
| 324 | 358 | makeTerminalContext({ |
| 325 | 359 | id: "ctx-2", |
| @@ -331,7 +365,7 @@ describe("composerDraftStore terminal contexts", () => { · 5 unmodified lines | ||
| 331 | 365 | ), |
| 332 | 366 | ).toBe(true); |
| 333 | 367 | |
| 334 | − const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | |
| 368 | + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | |
| 335 | 369 | expect(draft?.prompt).toBe( |
| 336 | 370 | `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`, |
| 337 | 371 | ); |
| @@ -341,7 +375,7 @@ describe("composerDraftStore terminal contexts", () => { · 3 unmodified lines | ||
| 341 | 375 | it("omits terminal context text from persisted drafts", () => { |
| 342 | 376 | useComposerDraftStore |
| 343 | 377 | .getState() |
| 344 | − .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-persist" })); | |
| 378 | + .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" })); | |
| 345 | 379 | |
| 346 | 380 | const persistApi = useComposerDraftStore.persist as unknown as { |
| 347 | 381 | getOptions: () => { |
| @@ -349,11 +383,12 @@ describe("composerDraftStore terminal contexts", () => { · 1 unmodified line | ||
| 349 | 383 | }; |
| 350 | 384 | }; |
| 351 | 385 | const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { |
| 352 | − draftsByThreadId?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>; | |
| 386 | + draftsByThreadKey?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>; | |
| 353 | 387 | }; |
| 354 | 388 | |
| 355 | 389 | expect( |
| 356 | − persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0], | |
| 390 | + persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] | |
| 391 | + ?.terminalContexts?.[0], | |
| 357 | 392 | "Expected terminal context metadata to be persisted.", |
| 358 | 393 | ).toMatchObject({ |
| 359 | 394 | id: "ctx-persist", |
| @@ -363,7 +398,8 @@ describe("composerDraftStore terminal contexts", () => { · 3 unmodified lines | ||
| 363 | 398 | lineEnd: 5, |
| 364 | 399 | }); |
| 365 | 400 | expect( |
| 366 | − persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0]?.text, | |
| 401 | + persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] | |
| 402 | + ?.terminalContexts?.[0]?.text, | |
| 367 | 403 | ).toBeUndefined(); |
| 368 | 404 | }); |
| 369 | 405 | |
| @@ -396,12 +432,12 @@ describe("composerDraftStore terminal contexts", () => { · 26 unmodified lines | ||
| 396 | 432 | }, |
| 397 | 433 | }, |
| 398 | 434 | draftThreadsByThreadId: {}, |
| 399 | − projectDraftThreadIdByProjectId: {}, | |
| 435 | + projectDraftThreadIdByProjectKey: {}, | |
| 400 | 436 | }, |
| 401 | 437 | useComposerDraftStore.getInitialState(), |
| 402 | 438 | ); |
| 403 | 439 | |
| 404 | − expect(mergedState.draftsByThreadId[threadId]?.terminalContexts).toMatchObject([ | |
| 440 | + expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.terminalContexts).toMatchObject([ | |
| 405 | 441 | { |
| 406 | 442 | id: "ctx-rehydrated", |
| 407 | 443 | terminalId: "default", |
| @@ -434,22 +470,30 @@ describe("composerDraftStore terminal contexts", () => { · 26 unmodified lines | ||
| 434 | 470 | }, |
| 435 | 471 | }, |
| 436 | 472 | draftThreadsByThreadId: "not-an-object", |
| 437 | − projectDraftThreadIdByProjectId: "not-an-object", | |
| 473 | + projectDraftThreadIdByProjectKey: "not-an-object", | |
| 438 | 474 | }, |
| 439 | 475 | useComposerDraftStore.getInitialState(), |
| 440 | 476 | ); |
| 441 | 477 | |
| 442 | − expect(mergedState.draftsByThreadId[threadId]).toBeUndefined(); | |
| 443 | − expect(mergedState.draftThreadsByThreadId).toEqual({}); | |
| 444 | − expect(mergedState.projectDraftThreadIdByProjectId).toEqual({}); | |
| 478 | + expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined(); | |
| 479 | + expect(mergedState.draftThreadsByThreadKey).toEqual({}); | |
| 480 | + expect(mergedState.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual({}); | |
| 445 | 481 | }); |
| 446 | 482 | }); |
| 447 | 483 | |
| 448 | 484 | describe("composerDraftStore project draft thread mapping", () => { |
| 449 | 485 | const projectId = ProjectId.makeUnsafe("project-a"); |
| 450 | 486 | const otherProjectId = ProjectId.makeUnsafe("project-b"); |
| 487 | + const projectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, projectId); | |
| 488 | + const otherProjectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, otherProjectId); | |
| 489 | + const remoteProjectRef = scopeProjectRef(OTHER_TEST_ENVIRONMENT_ID, projectId); | |
| 451 | 490 | const threadId = ThreadId.makeUnsafe("thread-a"); |
| 452 | 491 | const otherThreadId = ThreadId.makeUnsafe("thread-b"); |
| 492 | + const draftId = DraftId.makeUnsafe("draft-a"); | |
| 493 | + const otherDraftId = DraftId.makeUnsafe("draft-b"); | |
| 494 | + const sharedDraftId = DraftId.makeUnsafe("draft-shared"); | |
| 495 | + const localDraftId = DraftId.makeUnsafe("draft-local"); | |
| 496 | + const remoteDraftId = DraftId.makeUnsafe("draft-remote"); | |
| 453 | 497 | |
| 454 | 498 | beforeEach(() => { |
| 455 | 499 | resetComposerDraftStore(); |
| @@ -457,17 +501,20 @@ describe("composerDraftStore project draft thread mapping", () => { · 1 unmodified line | ||
| 457 | 501 | |
| 458 | 502 | it("stores and reads project draft thread ids via actions", () => { |
| 459 | 503 | const store = useComposerDraftStore.getState(); |
| 460 | − expect(store.getDraftThreadByProjectId(projectId)).toBeNull(); | |
| 461 | − expect(store.getDraftThread(threadId)).toBeNull(); | |
| 504 | + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); | |
| 505 | + expect(store.getDraftThread(draftId)).toBeNull(); | |
| 462 | 506 | |
| 463 | − store.setProjectDraftThreadId(projectId, threadId, { | |
| 507 | + store.setProjectDraftThreadId(projectRef, draftId, { | |
| 508 | + threadId, | |
| 464 | 509 | branch: "feature/test", |
| 465 | 510 | worktreePath: "/tmp/worktree-test", |
| 466 | 511 | createdAt: "2026-01-01T00:00:00.000Z", |
| 467 | 512 | }); |
| 468 | − expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({ | |
| 513 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toMatchObject({ | |
| 469 | 514 | threadId, |
| 515 | + environmentId: TEST_ENVIRONMENT_ID, | |
| 470 | 516 | projectId, |
| 517 | + logicalProjectKey: scopedProjectKey(projectRef), | |
| 471 | 518 | branch: "feature/test", |
| 472 | 519 | worktreePath: "/tmp/worktree-test", |
| 473 | 520 | envMode: "worktree", |
| @@ -475,8 +522,10 @@ describe("composerDraftStore project draft thread mapping", () => { · 1 unmodified line | ||
| 475 | 522 | interactionMode: "default", |
| 476 | 523 | createdAt: "2026-01-01T00:00:00.000Z", |
| 477 | 524 | }); |
| 478 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({ | |
| 525 | + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ | |
| 526 | + environmentId: TEST_ENVIRONMENT_ID, | |
| 479 | 527 | projectId, |
| 528 | + logicalProjectKey: scopedProjectKey(projectRef), | |
| 480 | 529 | branch: "feature/test", |
| 481 | 530 | worktreePath: "/tmp/worktree-test", |
| 482 | 531 | envMode: "worktree", |
| @@ -488,134 +537,210 @@ describe("composerDraftStore project draft thread mapping", () => { · 5 unmodified lines | ||
| 488 | 537 | |
| 489 | 538 | it("clears only matching project draft mapping entries", () => { |
| 490 | 539 | const store = useComposerDraftStore.getState(); |
| 491 | − store.setProjectDraftThreadId(projectId, threadId); | |
| 492 | − store.setPrompt(threadId, "hello"); | |
| 540 | + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 541 | + store.setPrompt(draftId, "hello"); | |
| 493 | 542 | |
| 494 | − store.clearProjectDraftThreadById(projectId, otherThreadId); | |
| 495 | − expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( | |
| 543 | + store.clearProjectDraftThreadById(projectRef, otherDraftId); | |
| 544 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( | |
| 496 | 545 | threadId, |
| 497 | 546 | ); |
| 498 | 547 | |
| 499 | − store.clearProjectDraftThreadById(projectId, threadId); | |
| 500 | − expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); | |
| 501 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | |
| 502 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | |
| 548 | + store.clearProjectDraftThreadById(projectRef, draftId); | |
| 549 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); | |
| 550 | + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); | |
| 551 | + expect(draftByKey(draftId)).toBeUndefined(); | |
| 503 | 552 | }); |
| 504 | 553 | |
| 505 | 554 | it("clears project draft mapping by project id", () => { |
| 506 | 555 | const store = useComposerDraftStore.getState(); |
| 507 | − store.setProjectDraftThreadId(projectId, threadId); | |
| 508 | − store.setPrompt(threadId, "hello"); | |
| 509 | − store.clearProjectDraftThreadId(projectId); | |
| 510 | − expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); | |
| 511 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | |
| 512 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | |
| 556 | + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 557 | + store.setPrompt(draftId, "hello"); | |
| 558 | + store.clearProjectDraftThreadId(projectRef); | |
| 559 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); | |
| 560 | + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); | |
| 561 | + expect(draftByKey(draftId)).toBeUndefined(); | |
| 513 | 562 | }); |
| 514 | 563 | |
| 515 | 564 | it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { |
| 516 | 565 | const store = useComposerDraftStore.getState(); |
| 517 | − store.setProjectDraftThreadId(projectId, threadId); | |
| 518 | − store.setPrompt(threadId, "orphan me"); | |
| 566 | + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 567 | + store.setPrompt(draftId, "orphan me"); | |
| 519 | 568 | |
| 520 | − store.setProjectDraftThreadId(projectId, otherThreadId); | |
| 569 | + store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); | |
| 521 | 570 | |
| 522 | − expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( | |
| 571 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( | |
| 523 | 572 | otherThreadId, |
| 524 | 573 | ); |
| 525 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | |
| 526 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | |
| 574 | + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); | |
| 575 | + expect(draftByKey(draftId)).toBeUndefined(); | |
| 527 | 576 | }); |
| 528 | 577 | |
| 529 | 578 | it("keeps composer drafts when the thread is still mapped by another project", () => { |
| 530 | 579 | const store = useComposerDraftStore.getState(); |
| 531 | − store.setProjectDraftThreadId(projectId, threadId); | |
| 532 | − store.setProjectDraftThreadId(otherProjectId, threadId); | |
| 533 | − store.setPrompt(threadId, "keep me"); | |
| 580 | + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 581 | + store.setProjectDraftThreadId(otherProjectRef, sharedDraftId, { threadId }); | |
| 582 | + store.setPrompt(sharedDraftId, "keep me"); | |
| 534 | 583 | |
| 535 | − store.clearProjectDraftThreadId(projectId); | |
| 584 | + store.clearProjectDraftThreadId(projectRef); | |
| 536 | 585 | |
| 537 | − expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); | |
| 586 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); | |
| 538 | 587 | expect( |
| 539 | − useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, | |
| 588 | + useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId, | |
| 540 | 589 | ).toBe(threadId); |
| 541 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); | |
| 590 | + expect(draftByKey(sharedDraftId)?.prompt).toBe("keep me"); | |
| 542 | 591 | }); |
| 543 | 592 | |
| 544 | 593 | it("clears draft registration independently", () => { |
| 545 | 594 | const store = useComposerDraftStore.getState(); |
| 546 | − store.setProjectDraftThreadId(projectId, threadId); | |
| 547 | − store.setPrompt(threadId, "remove me"); | |
| 548 | − store.clearDraftThread(threadId); | |
| 549 | − expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); | |
| 550 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | |
| 551 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | |
| 595 | + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 596 | + store.setPrompt(draftId, "remove me"); | |
| 597 | + store.clearDraftThread(draftId); | |
| 598 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); | |
| 599 | + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); | |
| 600 | + expect(draftByKey(draftId)).toBeUndefined(); | |
| 601 | + }); | |
| 602 | + | |
| 603 | + it("marks a promoted draft by thread id without deleting composer state", () => { | |
| 604 | + const store = useComposerDraftStore.getState(); | |
| 605 | + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 606 | + store.setPrompt(draftId, "promote me"); | |
| 607 | + | |
| 608 | + markPromotedDraftThread(threadId); | |
| 609 | + | |
| 610 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); | |
| 611 | + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( | |
| 612 | + scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), | |
| 613 | + ); | |
| 614 | + expect(draftByKey(draftId)?.prompt).toBe("promote me"); | |
| 552 | 615 | }); |
| 553 | 616 | |
| 554 | − it("clears a promoted draft by thread id", () => { | |
| 617 | + it("reads local draft composer state through a scoped thread ref", () => { | |
| 555 | 618 | const store = useComposerDraftStore.getState(); |
| 556 | − store.setProjectDraftThreadId(projectId, threadId); | |
| 557 | − store.setPrompt(threadId, "promote me"); | |
| 619 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 558 | 620 | |
| 559 | − clearPromotedDraftThread(threadId); | |
| 621 | + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 622 | + store.setPrompt(draftId, "scoped access"); | |
| 560 | 623 | |
| 561 | − expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); | |
| 562 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | |
| 563 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | |
| 624 | + expect(store.getComposerDraft(draftId)?.prompt).toBe("scoped access"); | |
| 625 | + expect(store.getComposerDraft(threadRef)?.prompt).toBe("scoped access"); | |
| 564 | 626 | }); |
| 565 | 627 | |
| 566 | 628 | it("does not clear composer drafts for existing server threads during promotion cleanup", () => { |
| 567 | 629 | const store = useComposerDraftStore.getState(); |
| 568 | − store.setPrompt(threadId, "keep me"); | |
| 630 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 631 | + store.setPrompt(threadRef, "keep me"); | |
| 569 | 632 | |
| 570 | − clearPromotedDraftThread(threadId); | |
| 633 | + markPromotedDraftThread(threadId); | |
| 571 | 634 | |
| 572 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | |
| 573 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); | |
| 635 | + expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); | |
| 636 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); | |
| 574 | 637 | }); |
| 575 | 638 | |
| 576 | − it("clears promoted drafts from an iterable of server thread ids", () => { | |
| 639 | + it("marks promoted drafts from an iterable of server thread ids", () => { | |
| 577 | 640 | const store = useComposerDraftStore.getState(); |
| 578 | − store.setProjectDraftThreadId(projectId, threadId); | |
| 579 | − store.setPrompt(threadId, "promote me"); | |
| 580 | − store.setProjectDraftThreadId(otherProjectId, otherThreadId); | |
| 581 | − store.setPrompt(otherThreadId, "keep me"); | |
| 641 | + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 642 | + store.setPrompt(draftId, "promote me"); | |
| 643 | + store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId }); | |
| 644 | + store.setPrompt(otherDraftId, "keep me"); | |
| 582 | 645 | |
| 583 | − clearPromotedDraftThreads([threadId]); | |
| 646 | + markPromotedDraftThreads([threadId]); | |
| 584 | 647 | |
| 585 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | |
| 586 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | |
| 648 | + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( | |
| 649 | + scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), | |
| 650 | + ); | |
| 651 | + expect(draftByKey(draftId)?.prompt).toBe("promote me"); | |
| 587 | 652 | expect( |
| 588 | − useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, | |
| 653 | + useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId, | |
| 589 | 654 | ).toBe(otherThreadId); |
| 590 | − expect(useComposerDraftStore.getState().draftsByThreadId[otherThreadId]?.prompt).toBe( | |
| 591 | − "keep me", | |
| 655 | + expect(draftByKey(otherDraftId)?.prompt).toBe("keep me"); | |
| 656 | + }); | |
| 657 | + | |
| 658 | + it("marks every matching scoped draft when multiple environments share a thread id", () => { | |
| 659 | + const store = useComposerDraftStore.getState(); | |
| 660 | + const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 661 | + const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId); | |
| 662 | + | |
| 663 | + store.setProjectDraftThreadId(projectRef, localDraftId, { threadId }); | |
| 664 | + store.setPrompt(localDraftId, "local draft"); | |
| 665 | + store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { threadId }); | |
| 666 | + store.setPrompt(remoteDraftId, "remote draft"); | |
| 667 | + | |
| 668 | + markPromotedDraftThread(threadId); | |
| 669 | + | |
| 670 | + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); | |
| 671 | + expect(store.getDraftThreadByProjectRef(remoteProjectRef)).toBeNull(); | |
| 672 | + expect(store.getDraftThreadByRef(localThreadRef)?.promotedTo).toEqual(localThreadRef); | |
| 673 | + expect(store.getDraftThreadByRef(remoteThreadRef)?.promotedTo).toEqual(remoteThreadRef); | |
| 674 | + expect(draftByKey(localDraftId)?.prompt).toBe("local draft"); | |
| 675 | + expect(draftByKey(remoteDraftId)?.prompt).toBe("remote draft"); | |
| 676 | + }); | |
| 677 | + | |
| 678 | + it("only marks promoted drafts for the matching environment ref", () => { | |
| 679 | + const store = useComposerDraftStore.getState(); | |
| 680 | + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 681 | + store.setPrompt(draftId, "promote me"); | |
| 682 | + | |
| 683 | + markPromotedDraftThreadByRef(scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId)); | |
| 684 | + | |
| 685 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( | |
| 686 | + threadId, | |
| 687 | + ); | |
| 688 | + expect(draftByKey(draftId)?.prompt).toBe("promote me"); | |
| 689 | + }); | |
| 690 | + | |
| 691 | + it("only marks iterable promotion cleanup entries for the matching environment refs", () => { | |
| 692 | + const store = useComposerDraftStore.getState(); | |
| 693 | + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 694 | + store.setPrompt(draftId, "promote me"); | |
| 695 | + | |
| 696 | + markPromotedDraftThreadsByRef([scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId)]); | |
| 697 | + | |
| 698 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( | |
| 699 | + threadId, | |
| 592 | 700 | ); |
| 701 | + expect(draftByKey(draftId)?.prompt).toBe("promote me"); | |
| 593 | 702 | }); |
| 594 | 703 | |
| 595 | 704 | it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => { |
| 596 | 705 | const store = useComposerDraftStore.getState(); |
| 597 | − store.setPrompt(threadId, "keep me"); | |
| 706 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 707 | + store.setPrompt(threadRef, "keep me"); | |
| 598 | 708 | |
| 599 | − clearPromotedDraftThreads([threadId]); | |
| 709 | + markPromotedDraftThreads([threadId]); | |
| 600 | 710 | |
| 601 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | |
| 602 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); | |
| 711 | + expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); | |
| 712 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); | |
| 713 | + }); | |
| 714 | + | |
| 715 | + it("finalizes a promoted draft after the canonical thread route is active", () => { | |
| 716 | + const store = useComposerDraftStore.getState(); | |
| 717 | + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 718 | + store.setPrompt(draftId, "promote me"); | |
| 719 | + markPromotedDraftThread(threadId); | |
| 720 | + | |
| 721 | + finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); | |
| 722 | + | |
| 723 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); | |
| 724 | + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); | |
| 725 | + expect(draftByKey(draftId)).toBeUndefined(); | |
| 603 | 726 | }); |
| 604 | 727 | |
| 605 | 728 | it("updates branch context on an existing draft thread", () => { |
| 606 | 729 | const store = useComposerDraftStore.getState(); |
| 607 | − store.setProjectDraftThreadId(projectId, threadId, { | |
| 730 | + store.setProjectDraftThreadId(projectRef, draftId, { | |
| 731 | + threadId, | |
| 608 | 732 | branch: "main", |
| 609 | 733 | worktreePath: null, |
| 610 | 734 | }); |
| 611 | − store.setDraftThreadContext(threadId, { | |
| 735 | + store.setDraftThreadContext(draftId, { | |
| 612 | 736 | branch: "feature/next", |
| 613 | 737 | worktreePath: "/tmp/feature-next", |
| 614 | 738 | }); |
| 615 | − expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( | |
| 739 | + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( | |
| 616 | 740 | threadId, |
| 617 | 741 | ); |
| 618 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ | |
| 742 | + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ | |
| 743 | + environmentId: TEST_ENVIRONMENT_ID, | |
| 619 | 744 | projectId, |
| 620 | 745 | branch: "feature/next", |
| 621 | 746 | worktreePath: "/tmp/feature-next", |
| @@ -625,7 +750,8 @@ describe("composerDraftStore project draft thread mapping", () => { · 3 unmodified lines | ||
| 625 | 750 | |
| 626 | 751 | it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { |
| 627 | 752 | const store = useComposerDraftStore.getState(); |
| 628 | − store.setProjectDraftThreadId(projectId, threadId, { | |
| 753 | + store.setProjectDraftThreadId(projectRef, draftId, { | |
| 754 | + threadId, | |
| 629 | 755 | branch: "main", |
| 630 | 756 | worktreePath: "/tmp/main-worktree", |
| 631 | 757 | }); |
| @@ -636,9 +762,10 @@ describe("composerDraftStore project draft thread mapping", () => { · 4 unmodified lines | ||
| 636 | 762 | branch?: string | null; |
| 637 | 763 | worktreePath?: string | null; |
| 638 | 764 | }; |
| 639 | − store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions); | |
| 765 | + store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions); | |
| 640 | 766 | |
| 641 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ | |
| 767 | + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ | |
| 768 | + environmentId: TEST_ENVIRONMENT_ID, | |
| 642 | 769 | projectId, |
| 643 | 770 | branch: "main", |
| 644 | 771 | worktreePath: "/tmp/main-worktree", |
| @@ -648,7 +775,8 @@ describe("composerDraftStore project draft thread mapping", () => { · 3 unmodified lines | ||
| 648 | 775 | |
| 649 | 776 | it("preserves worktree env mode without a worktree path", () => { |
| 650 | 777 | const store = useComposerDraftStore.getState(); |
| 651 | − store.setProjectDraftThreadId(projectId, threadId, { | |
| 778 | + store.setProjectDraftThreadId(projectRef, draftId, { | |
| 779 | + threadId, | |
| 652 | 780 | branch: "feature/base", |
| 653 | 781 | worktreePath: null, |
| 654 | 782 | envMode: "worktree", |
| @@ -662,9 +790,10 @@ describe("composerDraftStore project draft thread mapping", () => { · 7 unmodified lines | ||
| 662 | 790 | worktreePath?: string | null; |
| 663 | 791 | envMode?: "local" | "worktree"; |
| 664 | 792 | }; |
| 665 | − store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions); | |
| 793 | + store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions); | |
| 666 | 794 | |
| 667 | − expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ | |
| 795 | + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ | |
| 796 | + environmentId: TEST_ENVIRONMENT_ID, | |
| 668 | 797 | projectId, |
| 669 | 798 | branch: "feature/base", |
| 670 | 799 | worktreePath: null, |
| @@ -675,6 +804,7 @@ describe("composerDraftStore project draft thread mapping", () => { · 4 unmodified lines | ||
| 675 | 804 | |
| 676 | 805 | describe("composerDraftStore modelSelection", () => { |
| 677 | 806 | const threadId = ThreadId.makeUnsafe("thread-model-options"); |
| 807 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 678 | 808 | |
| 679 | 809 | beforeEach(() => { |
| 680 | 810 | resetComposerDraftStore(); |
| @@ -683,16 +813,14 @@ describe("composerDraftStore modelSelection", () => { · 2 unmodified lines | ||
| 683 | 813 | it("stores a model selection in the draft", () => { |
| 684 | 814 | const store = useComposerDraftStore.getState(); |
| 685 | 815 | store.setModelSelection( |
| 686 | − threadId, | |
| 816 | + threadRef, | |
| 687 | 817 | modelSelection("codex", "gpt-5.3-codex", { |
| 688 | 818 | reasoningEffort: "xhigh", |
| 689 | 819 | fastMode: true, |
| 690 | 820 | }), |
| 691 | 821 | ); |
| 692 | 822 | |
| 693 | − expect( | |
| 694 | − useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, | |
| 695 | − ).toEqual( | |
| 823 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( | |
| 696 | 824 | modelSelection("codex", "gpt-5.3-codex", { |
| 697 | 825 | reasoningEffort: "xhigh", |
| 698 | 826 | fastMode: true, |
| @@ -702,18 +830,18 @@ describe("composerDraftStore modelSelection", () => { · 3 unmodified lines | ||
| 702 | 830 | |
| 703 | 831 | it("keeps default-only model selections on the draft", () => { |
| 704 | 832 | const store = useComposerDraftStore.getState(); |
| 705 | − store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); | |
| 833 | + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4")); | |
| 706 | 834 | |
| 707 | − expect( | |
| 708 | − useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, | |
| 709 | − ).toEqual(modelSelection("codex", "gpt-5.4")); | |
| 835 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( | |
| 836 | + modelSelection("codex", "gpt-5.4"), | |
| 837 | + ); | |
| 710 | 838 | }); |
| 711 | 839 | |
| 712 | 840 | it("replaces only the targeted provider options on the current model selection", () => { |
| 713 | 841 | const store = useComposerDraftStore.getState(); |
| 714 | 842 | |
| 715 | 843 | store.setModelSelection( |
| 716 | − threadId, | |
| 844 | + threadRef, | |
| 717 | 845 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 718 | 846 | effort: "max", |
| 719 | 847 | fastMode: true, |
| @@ -727,7 +855,7 @@ describe("composerDraftStore modelSelection", () => { · 7 unmodified lines | ||
| 727 | 855 | ); |
| 728 | 856 | |
| 729 | 857 | store.setProviderModelOptions( |
| 730 | − threadId, | |
| 858 | + threadRef, | |
| 731 | 859 | "claudeAgent", |
| 732 | 860 | { |
| 733 | 861 | thinking: false, |
| @@ -735,10 +863,7 @@ describe("composerDraftStore modelSelection", () => { · 1 unmodified line | ||
| 735 | 863 | { persistSticky: true }, |
| 736 | 864 | ); |
| 737 | 865 | |
| 738 | − expect( | |
| 739 | − useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider | |
| 740 | − .claudeAgent, | |
| 741 | − ).toEqual( | |
| 866 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( | |
| 742 | 867 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 743 | 868 | thinking: false, |
| 744 | 869 | }), |
| @@ -754,20 +879,17 @@ describe("composerDraftStore modelSelection", () => { · 9 unmodified lines | ||
| 754 | 879 | const store = useComposerDraftStore.getState(); |
| 755 | 880 | |
| 756 | 881 | store.setModelSelection( |
| 757 | − threadId, | |
| 882 | + threadRef, | |
| 758 | 883 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 759 | 884 | effort: "max", |
| 760 | 885 | }), |
| 761 | 886 | ); |
| 762 | 887 | |
| 763 | − store.setProviderModelOptions(threadId, "claudeAgent", { | |
| 888 | + store.setProviderModelOptions(threadRef, "claudeAgent", { | |
| 764 | 889 | thinking: true, |
| 765 | 890 | }); |
| 766 | 891 | |
| 767 | − expect( | |
| 768 | − useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider | |
| 769 | − .claudeAgent, | |
| 770 | − ).toEqual( | |
| 892 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( | |
| 771 | 893 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 772 | 894 | thinking: true, |
| 773 | 895 | }), |
| @@ -778,16 +900,14 @@ describe("composerDraftStore modelSelection", () => { · 4 unmodified lines | ||
| 778 | 900 | it("keeps explicit off/default codex overrides on the selection", () => { |
| 779 | 901 | const store = useComposerDraftStore.getState(); |
| 780 | 902 | |
| 781 | − store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true })); | |
| 903 | + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true })); | |
| 782 | 904 | |
| 783 | − store.setProviderModelOptions(threadId, "codex", { | |
| 905 | + store.setProviderModelOptions(threadRef, "codex", { | |
| 784 | 906 | reasoningEffort: "high", |
| 785 | 907 | fastMode: false, |
| 786 | 908 | }); |
| 787 | 909 | |
| 788 | − expect( | |
| 789 | − useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, | |
| 790 | − ).toEqual( | |
| 910 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( | |
| 791 | 911 | modelSelection("codex", "gpt-5.4", { |
| 792 | 912 | reasoningEffort: "high", |
| 793 | 913 | fastMode: false, |
| @@ -802,18 +922,15 @@ describe("composerDraftStore modelSelection", () => { · 8 unmodified lines | ||
| 802 | 922 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 803 | 923 | ); |
| 804 | 924 | store.setModelSelection( |
| 805 | − threadId, | |
| 925 | + threadRef, | |
| 806 | 926 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 807 | 927 | ); |
| 808 | 928 | |
| 809 | − store.setProviderModelOptions(threadId, "claudeAgent", { | |
| 929 | + store.setProviderModelOptions(threadRef, "claudeAgent", { | |
| 810 | 930 | thinking: false, |
| 811 | 931 | }); |
| 812 | 932 | |
| 813 | − expect( | |
| 814 | − useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider | |
| 815 | − .claudeAgent, | |
| 816 | − ).toEqual( | |
| 933 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( | |
| 817 | 934 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 818 | 935 | thinking: false, |
| 819 | 936 | }), |
| @@ -828,7 +945,7 @@ describe("composerDraftStore modelSelection", () => { · 8 unmodified lines | ||
| 828 | 945 | |
| 829 | 946 | // Set options for both providers |
| 830 | 947 | store.setModelOptions( |
| 831 | − threadId, | |
| 948 | + threadRef, | |
| 832 | 949 | providerModelOptions({ |
| 833 | 950 | codex: { fastMode: true }, |
| 834 | 951 | claudeAgent: { effort: "max" }, |
| @@ -836,9 +953,9 @@ describe("composerDraftStore modelSelection", () => { · 1 unmodified line | ||
| 836 | 953 | ); |
| 837 | 954 | |
| 838 | 955 | // Now set options for only codex — claudeAgent should be untouched |
| 839 | − store.setModelOptions(threadId, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); | |
| 956 | + store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); | |
| 840 | 957 | |
| 841 | − const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | |
| 958 | + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | |
| 842 | 959 | expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); |
| 843 | 960 | expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); |
| 844 | 961 | }); |
| @@ -847,16 +964,16 @@ describe("composerDraftStore modelSelection", () => { · 2 unmodified lines | ||
| 847 | 964 | const store = useComposerDraftStore.getState(); |
| 848 | 965 | |
| 849 | 966 | store.setModelOptions( |
| 850 | − threadId, | |
| 967 | + threadRef, | |
| 851 | 968 | providerModelOptions({ |
| 852 | 969 | codex: { fastMode: true }, |
| 853 | 970 | claudeAgent: { effort: "max" }, |
| 854 | 971 | }), |
| 855 | 972 | ); |
| 856 | 973 | |
| 857 | − store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6")); | |
| 974 | + store.setModelSelection(threadRef, modelSelection("claudeAgent", "claude-opus-4-6")); | |
| 858 | 975 | |
| 859 | − const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | |
| 976 | + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | |
| 860 | 977 | expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( |
| 861 | 978 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 862 | 979 | ); |
| @@ -867,10 +984,10 @@ describe("composerDraftStore modelSelection", () => { · 4 unmodified lines | ||
| 867 | 984 | it("creates the first sticky snapshot from provider option changes", () => { |
| 868 | 985 | const store = useComposerDraftStore.getState(); |
| 869 | 986 | |
| 870 | − store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); | |
| 987 | + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4")); | |
| 871 | 988 | |
| 872 | 989 | store.setProviderModelOptions( |
| 873 | − threadId, | |
| 990 | + threadRef, | |
| 874 | 991 | "codex", |
| 875 | 992 | { |
| 876 | 993 | fastMode: true, |
| @@ -892,12 +1009,12 @@ describe("composerDraftStore modelSelection", () => { · 15 unmodified lines | ||
| 892 | 1009 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 893 | 1010 | ); |
| 894 | 1011 | store.setModelSelection( |
| 895 | − threadId, | |
| 1012 | + threadRef, | |
| 896 | 1013 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 897 | 1014 | ); |
| 898 | 1015 | |
| 899 | 1016 | store.setProviderModelOptions( |
| 900 | − threadId, | |
| 1017 | + threadRef, | |
| 901 | 1018 | "claudeAgent", |
| 902 | 1019 | { |
| 903 | 1020 | thinking: false, |
| @@ -905,10 +1022,7 @@ describe("composerDraftStore modelSelection", () => { · 1 unmodified line | ||
| 905 | 1022 | { persistSticky: false }, |
| 906 | 1023 | ); |
| 907 | 1024 | |
| 908 | − expect( | |
| 909 | − useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider | |
| 910 | − .claudeAgent, | |
| 911 | − ).toEqual( | |
| 1025 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( | |
| 912 | 1026 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 913 | 1027 | thinking: false, |
| 914 | 1028 | }), |
| @@ -921,6 +1035,7 @@ describe("composerDraftStore modelSelection", () => { · 6 unmodified lines | ||
| 921 | 1035 | |
| 922 | 1036 | describe("composerDraftStore setModelSelection", () => { |
| 923 | 1037 | const threadId = ThreadId.makeUnsafe("thread-model"); |
| 1038 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 924 | 1039 | |
| 925 | 1040 | beforeEach(() => { |
| 926 | 1041 | resetComposerDraftStore(); |
| @@ -929,11 +1044,11 @@ describe("composerDraftStore setModelSelection", () => { · 2 unmodified lines | ||
| 929 | 1044 | it("keeps explicit model overrides instead of coercing to null", () => { |
| 930 | 1045 | const store = useComposerDraftStore.getState(); |
| 931 | 1046 | |
| 932 | − store.setModelSelection(threadId, modelSelection("codex", "gpt-5.3-codex")); | |
| 1047 | + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.3-codex")); | |
| 933 | 1048 | |
| 934 | − expect( | |
| 935 | − useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, | |
| 936 | − ).toEqual(modelSelection("codex", "gpt-5.3-codex")); | |
| 1049 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( | |
| 1050 | + modelSelection("codex", "gpt-5.3-codex"), | |
| 1051 | + ); | |
| 937 | 1052 | }); |
| 938 | 1053 | }); |
| 939 | 1054 | |
| @@ -975,11 +1090,12 @@ describe("composerDraftStore sticky composer settings", () => { · 35 unmodified lines | ||
| 975 | 1090 | it("applies sticky activeProvider to new drafts", () => { |
| 976 | 1091 | const store = useComposerDraftStore.getState(); |
| 977 | 1092 | const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider"); |
| 1093 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 978 | 1094 | |
| 979 | 1095 | store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6")); |
| 980 | − store.applyStickyState(threadId); | |
| 1096 | + store.applyStickyState(threadRef); | |
| 981 | 1097 | |
| 982 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ | |
| 1098 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({ | |
| 983 | 1099 | modelSelectionByProvider: { |
| 984 | 1100 | claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"), |
| 985 | 1101 | }, |
| @@ -990,6 +1106,7 @@ describe("composerDraftStore sticky composer settings", () => { · 4 unmodified lines | ||
| 990 | 1106 | |
| 991 | 1107 | describe("composerDraftStore provider-scoped option updates", () => { |
| 992 | 1108 | const threadId = ThreadId.makeUnsafe("thread-provider"); |
| 1109 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 993 | 1110 | |
| 994 | 1111 | beforeEach(() => { |
| 995 | 1112 | resetComposerDraftStore(); |
| @@ -998,13 +1115,13 @@ describe("composerDraftStore provider-scoped option updates", () => { · 2 unmodified lines | ||
| 998 | 1115 | it("retains off-provider option memory without changing the active selection", () => { |
| 999 | 1116 | const store = useComposerDraftStore.getState(); |
| 1000 | 1117 | store.setModelSelection( |
| 1001 | − threadId, | |
| 1118 | + threadRef, | |
| 1002 | 1119 | modelSelection("codex", "gpt-5.3-codex", { |
| 1003 | 1120 | reasoningEffort: "medium", |
| 1004 | 1121 | }), |
| 1005 | 1122 | ); |
| 1006 | − store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" }); | |
| 1007 | − const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | |
| 1123 | + store.setProviderModelOptions(threadRef, "claudeAgent", { effort: "max" }); | |
| 1124 | + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); | |
| 1008 | 1125 | expect(draft?.modelSelectionByProvider.codex).toEqual( |
| 1009 | 1126 | modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), |
| 1010 | 1127 | ); |
| @@ -1015,6 +1132,7 @@ describe("composerDraftStore provider-scoped option updates", () => { · 4 unmodified lines | ||
| 1015 | 1132 | |
| 1016 | 1133 | describe("composerDraftStore runtime and interaction settings", () => { |
| 1017 | 1134 | const threadId = ThreadId.makeUnsafe("thread-settings"); |
| 1135 | + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | |
| 1018 | 1136 | |
| 1019 | 1137 | beforeEach(() => { |
| 1020 | 1138 | resetComposerDraftStore(); |
| @@ -1023,32 +1141,28 @@ describe("composerDraftStore runtime and interaction settings", () => { · 2 unmodified lines | ||
| 1023 | 1141 | it("stores runtime mode overrides in the composer draft", () => { |
| 1024 | 1142 | const store = useComposerDraftStore.getState(); |
| 1025 | 1143 | |
| 1026 | − store.setRuntimeMode(threadId, "approval-required"); | |
| 1144 | + store.setRuntimeMode(threadRef, "approval-required"); | |
| 1027 | 1145 | |
| 1028 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.runtimeMode).toBe( | |
| 1029 | − "approval-required", | |
| 1030 | − ); | |
| 1146 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.runtimeMode).toBe("approval-required"); | |
| 1031 | 1147 | }); |
| 1032 | 1148 | |
| 1033 | 1149 | it("stores interaction mode overrides in the composer draft", () => { |
| 1034 | 1150 | const store = useComposerDraftStore.getState(); |
| 1035 | 1151 | |
| 1036 | − store.setInteractionMode(threadId, "plan"); | |
| 1152 | + store.setInteractionMode(threadRef, "plan"); | |
| 1037 | 1153 | |
| 1038 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.interactionMode).toBe( | |
| 1039 | − "plan", | |
| 1040 | − ); | |
| 1154 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.interactionMode).toBe("plan"); | |
| 1041 | 1155 | }); |
| 1042 | 1156 | |
| 1043 | 1157 | it("removes empty settings-only drafts when overrides are cleared", () => { |
| 1044 | 1158 | const store = useComposerDraftStore.getState(); |
| 1045 | 1159 | |
| 1046 | − store.setRuntimeMode(threadId, "approval-required"); | |
| 1047 | − store.setInteractionMode(threadId, "plan"); | |
| 1048 | − store.setRuntimeMode(threadId, null); | |
| 1049 | − store.setInteractionMode(threadId, null); | |
| 1160 | + store.setRuntimeMode(threadRef, "approval-required"); | |
| 1161 | + store.setInteractionMode(threadRef, "plan"); | |
| 1162 | + store.setRuntimeMode(threadRef, null); | |
| 1163 | + store.setInteractionMode(threadRef, null); | |
| 1050 | 1164 | |
| 1051 | − expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | |
| 1165 | + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); | |
| 1052 | 1166 | }); |
| 1053 | 1167 | }); |
| 1054 | 1168 | |
| @@ -3,6 +3,7 @@ import { · 2 unmodified lines | ||
| 3 | 3 | type ClaudeCodeEffort, |
| 4 | 4 | type CodexReasoningEffort, |
| 5 | 5 | DEFAULT_MODEL_BY_PROVIDER, |
| 6 | + type EnvironmentId, | |
| 6 | 7 | ModelSelection, |
| 7 | 8 | ProjectId, |
| 8 | 9 | ProviderInteractionMode, |
| @@ -10,8 +11,18 @@ import { · 1 unmodified line | ||
| 10 | 11 | ProviderModelOptions, |
| 11 | 12 | RuntimeMode, |
| 12 | 13 | type ServerProvider, |
| 14 | + type ScopedProjectRef, | |
| 15 | + type ScopedThreadRef, | |
| 13 | 16 | ThreadId, |
| 14 | 17 | } from "@t3tools/contracts"; |
| 18 | +import { | |
| 19 | + parseScopedProjectKey, | |
| 20 | + parseScopedThreadKey, | |
| 21 | + scopedProjectKey, | |
| 22 | + scopeProjectRef, | |
| 23 | + scopedThreadKey, | |
| 24 | + scopeThreadRef, | |
| 25 | +} from "@t3tools/client-runtime"; | |
| 15 | 26 | import * as Schema from "effect/Schema"; |
| 16 | 27 | import * as Equal from "effect/Equal"; |
| 17 | 28 | import { DeepMutable } from "effect/Types"; |
| @@ -32,10 +43,13 @@ import { getDefaultServerModel } from "./providerModels"; · 14 unmodified lines | ||
| 32 | 43 | import { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 33 | 44 | |
| 34 | 45 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; |
| 35 | −const COMPOSER_DRAFT_STORAGE_VERSION = 3; | |
| 46 | +const COMPOSER_DRAFT_STORAGE_VERSION = 5; | |
| 36 | 47 | const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); |
| 37 | 48 | export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; |
| 38 | 49 | |
| 50 | +export const DraftId = Schema.String.pipe(Schema.brand("DraftId")); | |
| 51 | +export type DraftId = typeof DraftId.Type; | |
| 52 | + | |
| 39 | 53 | const COMPOSER_PERSIST_DEBOUNCE_MS = 300; |
| 40 | 54 | |
| 41 | 55 | const composerDebouncedStorage = createDebouncedStorage( |
| @@ -44,7 +58,7 @@ const composerDebouncedStorage = createDebouncedStorage( · 2 unmodified lines | ||
| 44 | 58 | ); |
| 45 | 59 | |
| 46 | 60 | // Flush pending composer draft writes before page unload to prevent data loss. |
| 47 | −if (typeof window !== "undefined") { | |
| 61 | +if (typeof window !== "undefined" && typeof window.addEventListener === "function") { | |
| 48 | 62 | window.addEventListener("beforeunload", () => { |
| 49 | 63 | composerDebouncedStorage.flush(); |
| 50 | 64 | }); |
| @@ -122,6 +136,14 @@ type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; · 71 unmodified lines | ||
| 122 | 136 | type LegacyV2StoreFields = { |
| 123 | 137 | stickyModelSelection?: ModelSelection | null; |
| 124 | 138 | stickyModelOptions?: ProviderModelOptions | null; |
| 139 | + projectDraftThreadIdByProjectId?: Record<string, string> | null; | |
| 140 | + draftsByThreadId?: Record<string, PersistedComposerThreadDraftState> | null; | |
| 141 | + draftThreadsByThreadId?: Record<string, PersistedDraftThreadState> | null; | |
| 142 | + projectDraftThreadIdByProjectKey?: Record<string, string> | null; | |
| 143 | + draftsByThreadKey?: Record<string, PersistedComposerThreadDraftState> | null; | |
| 144 | + draftThreadsByThreadKey?: Record<string, PersistedDraftThreadState> | null; | |
| 145 | + projectDraftThreadKeyByProjectKey?: Record<string, string> | null; | |
| 146 | + logicalProjectDraftThreadKeyByLogicalProjectKey?: Record<string, string> | null; | |
| 125 | 147 | }; |
| 126 | 148 | |
| 127 | 149 | type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & |
| @@ -129,20 +151,31 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & · 1 unmodified line | ||
| 129 | 151 | LegacyV2StoreFields; |
| 130 | 152 | |
| 131 | 153 | const PersistedDraftThreadState = Schema.Struct({ |
| 154 | + threadId: ThreadId, | |
| 155 | + environmentId: Schema.String, | |
| 132 | 156 | projectId: ProjectId, |
| 157 | + logicalProjectKey: Schema.optionalKey(Schema.String), | |
| 133 | 158 | createdAt: Schema.String, |
| 134 | 159 | runtimeMode: RuntimeMode, |
| 135 | 160 | interactionMode: ProviderInteractionMode, |
| 136 | 161 | branch: Schema.NullOr(Schema.String), |
| 137 | 162 | worktreePath: Schema.NullOr(Schema.String), |
| 138 | 163 | envMode: DraftThreadEnvModeSchema, |
| 164 | + promotedTo: Schema.optionalKey( | |
| 165 | + Schema.NullOr( | |
| 166 | + Schema.Struct({ | |
| 167 | + environmentId: Schema.String, | |
| 168 | + threadId: Schema.String, | |
| 169 | + }), | |
| 170 | + ), | |
| 171 | + ), | |
| 139 | 172 | }); |
| 140 | 173 | type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; |
| 141 | 174 | |
| 142 | 175 | const PersistedComposerDraftStoreState = Schema.Struct({ |
| 143 | − draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), | |
| 144 | − draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), | |
| 145 | − projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), | |
| 176 | + draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState), | |
| 177 | + draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState), | |
| 178 | + logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String), | |
| 146 | 179 | stickyModelSelectionByProvider: Schema.optionalKey( |
| 147 | 180 | Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), |
| 148 | 181 | ), |
| @@ -155,6 +188,10 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ · 6 unmodified lines | ||
| 155 | 188 | state: PersistedComposerDraftStoreState, |
| 156 | 189 | }); |
| 157 | 190 | |
| 191 | +/** | |
| 192 | + * Composer content keyed by either a draft session (`DraftId`) or a real server | |
| 193 | + * thread (`ScopedThreadRef`). This is the editable payload shown in the composer. | |
| 194 | + */ | |
| 158 | 195 | export interface ComposerThreadDraftState { |
| 159 | 196 | prompt: string; |
| 160 | 197 | images: ComposerImageAttachment[]; |
| @@ -167,32 +204,94 @@ export interface ComposerThreadDraftState { · 6 unmodified lines | ||
| 167 | 204 | interactionMode: ProviderInteractionMode | null; |
| 168 | 205 | } |
| 169 | 206 | |
| 170 | −export interface DraftThreadState { | |
| 207 | +/** | |
| 208 | + * Mutable routing and execution context for a pre-thread draft session. | |
| 209 | + * | |
| 210 | + * Unlike a real server thread, a draft session can still change target | |
| 211 | + * environment/worktree configuration before the first send. | |
| 212 | + */ | |
| 213 | +export interface DraftSessionState { | |
| 214 | + threadId: ThreadId; | |
| 215 | + environmentId: EnvironmentId; | |
| 171 | 216 | projectId: ProjectId; |
| 217 | + logicalProjectKey: string; | |
| 172 | 218 | createdAt: string; |
| 173 | 219 | runtimeMode: RuntimeMode; |
| 174 | 220 | interactionMode: ProviderInteractionMode; |
| 175 | 221 | branch: string | null; |
| 176 | 222 | worktreePath: string | null; |
| 177 | 223 | envMode: DraftThreadEnvMode; |
| 224 | + promotedTo?: ScopedThreadRef | null; | |
| 178 | 225 | } |
| 179 | 226 | |
| 180 | −interface ProjectDraftThread extends DraftThreadState { | |
| 181 | − threadId: ThreadId; | |
| 227 | +export type DraftThreadState = DraftSessionState; | |
| 228 | + | |
| 229 | +/** | |
| 230 | + * Draft session metadata paired with its stable draft-session identity. | |
| 231 | + */ | |
| 232 | +interface ProjectDraftSession extends DraftSessionState { | |
| 233 | + draftId: DraftId; | |
| 182 | 234 | } |
| 183 | 235 | |
| 236 | +/** | |
| 237 | + * App-facing composer identity: | |
| 238 | + * - `DraftId` for pre-thread draft sessions | |
| 239 | + * - `ScopedThreadRef` for server-backed threads | |
| 240 | + * | |
| 241 | + * Raw `ThreadId` is intentionally excluded so callers cannot drop environment | |
| 242 | + * identity for real threads. | |
| 243 | + */ | |
| 244 | +type ComposerThreadTarget = ScopedThreadRef | DraftId; | |
| 245 | + | |
| 246 | +/** | |
| 247 | + * Persisted store for composer content plus draft-session metadata. | |
| 248 | + * | |
| 249 | + * The store intentionally models two domains: | |
| 250 | + * - draft sessions keyed by `DraftId` | |
| 251 | + * - server thread composer state keyed by `ScopedThreadRef` | |
| 252 | + */ | |
| 184 | 253 | interface ComposerDraftStoreState { |
| 185 | − draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>; | |
| 186 | − draftThreadsByThreadId: Record<ThreadId, DraftThreadState>; | |
| 187 | − projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; | |
| 254 | + draftsByThreadKey: Record<string, ComposerThreadDraftState>; | |
| 255 | + draftThreadsByThreadKey: Record<string, DraftThreadState>; | |
| 256 | + logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>; | |
| 188 | 257 | stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; |
| 189 | 258 | stickyActiveProvider: ProviderKind | null; |
| 190 | − getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; | |
| 191 | − getDraftThread: (threadId: ThreadId) => DraftThreadState | null; | |
| 259 | + /** Returns the editable composer content for a draft session or server thread. */ | |
| 260 | + getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null; | |
| 261 | + /** Looks up the active draft session for a logical project identity. */ | |
| 262 | + getDraftThreadByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null; | |
| 263 | + getDraftSessionByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null; | |
| 264 | + getDraftThreadByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; | |
| 265 | + getDraftSessionByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; | |
| 266 | + /** Reads mutable draft-session metadata by `DraftId`. */ | |
| 267 | + getDraftSession: (draftId: DraftId) => DraftSessionState | null; | |
| 268 | + /** Resolves a server-thread ref back to a matching draft session when one exists. */ | |
| 269 | + getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null; | |
| 270 | + getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null; | |
| 271 | + getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; | |
| 272 | + listDraftThreadKeys: () => string[]; | |
| 273 | + hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean; | |
| 274 | + /** Creates or updates the draft session tracked for a logical project. */ | |
| 275 | + setLogicalProjectDraftThreadId: ( | |
| 276 | + logicalProjectKey: string, | |
| 277 | + projectRef: ScopedProjectRef, | |
| 278 | + draftId: DraftId, | |
| 279 | + options?: { | |
| 280 | + threadId?: ThreadId; | |
| 281 | + branch?: string | null; | |
| 282 | + worktreePath?: string | null; | |
| 283 | + createdAt?: string; | |
| 284 | + envMode?: DraftThreadEnvMode; | |
| 285 | + runtimeMode?: RuntimeMode; | |
| 286 | + interactionMode?: ProviderInteractionMode; | |
| 287 | + }, | |
| 288 | + ) => void; | |
| 289 | + /** Creates or updates the draft session tracked for a concrete project ref. */ | |
| 192 | 290 | setProjectDraftThreadId: ( |
| 193 | − projectId: ProjectId, | |
| 194 | − threadId: ThreadId, | |
| 291 | + projectRef: ScopedProjectRef, | |
| 292 | + draftId: DraftId, | |
| 195 | 293 | options?: { |
| 294 | + threadId?: ThreadId; | |
| 196 | 295 | branch?: string | null; |
| 197 | 296 | worktreePath?: string | null; |
| 198 | 297 | createdAt?: string; |
| @@ -201,65 +300,76 @@ interface ComposerDraftStoreState { · 2 unmodified lines | ||
| 201 | 300 | interactionMode?: ProviderInteractionMode; |
| 202 | 301 | }, |
| 203 | 302 | ) => void; |
| 303 | + /** Updates mutable draft-session metadata without touching composer content. */ | |
| 204 | 304 | setDraftThreadContext: ( |
| 205 | − threadId: ThreadId, | |
| 305 | + threadRef: ComposerThreadTarget, | |
| 206 | 306 | options: { |
| 207 | 307 | branch?: string | null; |
| 208 | 308 | worktreePath?: string | null; |
| 209 | − projectId?: ProjectId; | |
| 309 | + projectRef?: ScopedProjectRef; | |
| 210 | 310 | createdAt?: string; |
| 211 | 311 | envMode?: DraftThreadEnvMode; |
| 212 | 312 | runtimeMode?: RuntimeMode; |
| 213 | 313 | interactionMode?: ProviderInteractionMode; |
| 214 | 314 | }, |
| 215 | 315 | ) => void; |
| 216 | − clearProjectDraftThreadId: (projectId: ProjectId) => void; | |
| 217 | − clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; | |
| 218 | − clearDraftThread: (threadId: ThreadId) => void; | |
| 316 | + clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; | |
| 317 | + clearProjectDraftThreadById: ( | |
| 318 | + projectRef: ScopedProjectRef, | |
| 319 | + threadRef: ComposerThreadTarget, | |
| 320 | + ) => void; | |
| 321 | + /** Marks a draft session as being promoted to a real server thread. */ | |
| 322 | + markDraftThreadPromoting: (threadRef: ComposerThreadTarget, promotedTo?: ScopedThreadRef) => void; | |
| 323 | + /** Removes draft-session metadata after promotion is complete. */ | |
| 324 | + finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void; | |
| 325 | + clearDraftThread: (threadRef: ComposerThreadTarget) => void; | |
| 219 | 326 | setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; |
| 220 | − setPrompt: (threadId: ThreadId, prompt: string) => void; | |
| 221 | − setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; | |
| 327 | + setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; | |
| 328 | + setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; | |
| 222 | 329 | setModelSelection: ( |
| 223 | − threadId: ThreadId, | |
| 330 | + threadRef: ComposerThreadTarget, | |
| 224 | 331 | modelSelection: ModelSelection | null | undefined, |
| 225 | 332 | ) => void; |
| 226 | 333 | setModelOptions: ( |
| 227 | − threadId: ThreadId, | |
| 334 | + threadRef: ComposerThreadTarget, | |
| 228 | 335 | modelOptions: ProviderModelOptions | null | undefined, |
| 229 | 336 | ) => void; |
| 230 | − applyStickyState: (threadId: ThreadId) => void; | |
| 337 | + applyStickyState: (threadRef: ComposerThreadTarget) => void; | |
| 231 | 338 | setProviderModelOptions: ( |
| 232 | − threadId: ThreadId, | |
| 339 | + threadRef: ComposerThreadTarget, | |
| 233 | 340 | provider: ProviderKind, |
| 234 | 341 | nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, |
| 235 | 342 | options?: { |
| 236 | 343 | persistSticky?: boolean; |
| 237 | 344 | }, |
| 238 | 345 | ) => void; |
| 239 | − setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; | |
| 346 | + setRuntimeMode: ( | |
| 347 | + threadRef: ComposerThreadTarget, | |
| 348 | + runtimeMode: RuntimeMode | null | undefined, | |
| 349 | + ) => void; | |
| 240 | 350 | setInteractionMode: ( |
| 241 | − threadId: ThreadId, | |
| 351 | + threadRef: ComposerThreadTarget, | |
| 242 | 352 | interactionMode: ProviderInteractionMode | null | undefined, |
| 243 | 353 | ) => void; |
| 244 | − addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; | |
| 245 | − addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; | |
| 246 | − removeImage: (threadId: ThreadId, imageId: string) => void; | |
| 354 | + addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void; | |
| 355 | + addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void; | |
| 356 | + removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void; | |
| 247 | 357 | insertTerminalContext: ( |
| 248 | − threadId: ThreadId, | |
| 358 | + threadRef: ComposerThreadTarget, | |
| 249 | 359 | prompt: string, |
| 250 | 360 | context: TerminalContextDraft, |
| 251 | 361 | index: number, |
| 252 | 362 | ) => boolean; |
| 253 | − addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void; | |
| 254 | − addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; | |
| 255 | − removeTerminalContext: (threadId: ThreadId, contextId: string) => void; | |
| 256 | − clearTerminalContexts: (threadId: ThreadId) => void; | |
| 257 | − clearPersistedAttachments: (threadId: ThreadId) => void; | |
| 363 | + addTerminalContext: (threadRef: ComposerThreadTarget, context: TerminalContextDraft) => void; | |
| 364 | + addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; | |
| 365 | + removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void; | |
| 366 | + clearTerminalContexts: (threadRef: ComposerThreadTarget) => void; | |
| 367 | + clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; | |
| 258 | 368 | syncPersistedAttachments: ( |
| 259 | − threadId: ThreadId, | |
| 369 | + threadRef: ComposerThreadTarget, | |
| 260 | 370 | attachments: PersistedComposerImageAttachment[], |
| 261 | 371 | ) => void; |
| 262 | − clearComposerContent: (threadId: ThreadId) => void; | |
| 372 | + clearComposerContent: (threadRef: ComposerThreadTarget) => void; | |
| 263 | 373 | } |
| 264 | 374 | |
| 265 | 375 | export interface EffectiveComposerModelState { |
| @@ -293,9 +403,9 @@ function modelSelectionByProviderToOptions( · 27 unmodified lines | ||
| 293 | 403 | } |
| 294 | 404 | |
| 295 | 405 | const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ |
| 296 | − draftsByThreadId: {}, | |
| 297 | − draftThreadsByThreadId: {}, | |
| 298 | − projectDraftThreadIdByProjectId: {}, | |
| 406 | + draftsByThreadKey: {}, | |
| 407 | + draftThreadsByThreadKey: {}, | |
| 408 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 299 | 409 | stickyModelSelectionByProvider: {}, |
| 300 | 410 | stickyActiveProvider: null, |
| 301 | 411 | }); |
| @@ -748,35 +858,323 @@ function normalizeDraftThreadEnvMode( · 446 unmodified lines | ||
| 748 | 858 | return fallbackWorktreePath ? "worktree" : "local"; |
| 749 | 859 | } |
| 750 | 860 | |
| 861 | +function projectDraftKey(projectRef: ScopedProjectRef): string { | |
| 862 | + return scopedProjectKey(projectRef); | |
| 863 | +} | |
| 864 | + | |
| 865 | +function logicalProjectDraftKey(logicalProjectKey: string): string { | |
| 866 | + return logicalProjectKey.trim(); | |
| 867 | +} | |
| 868 | + | |
| 869 | +/** | |
| 870 | + * Runtime composer storage key for app-facing identities only. | |
| 871 | + * | |
| 872 | + * Draft sessions are keyed by `DraftId`. Real threads are keyed by | |
| 873 | + * `ScopedThreadRef` so environment identity is always preserved. | |
| 874 | + */ | |
| 875 | +function composerTargetKey(target: ScopedThreadRef | DraftId): string { | |
| 876 | + if (typeof target === "string") { | |
| 877 | + return target.trim(); | |
| 878 | + } | |
| 879 | + return scopedThreadKey(target); | |
| 880 | +} | |
| 881 | + | |
| 882 | +/** | |
| 883 | + * Legacy persisted data may still be keyed by a raw `ThreadId`. This helper is | |
| 884 | + * intentionally migration-only so live code cannot accidentally accept that | |
| 885 | + * incomplete identity. | |
| 886 | + */ | |
| 887 | +function normalizeLegacyComposerStorageKey( | |
| 888 | + threadKeyOrId: string, | |
| 889 | + options?: { | |
| 890 | + environmentId?: EnvironmentId; | |
| 891 | + }, | |
| 892 | +): string { | |
| 893 | + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); | |
| 894 | + if (parsedThreadRef) { | |
| 895 | + return composerTargetKey(parsedThreadRef); | |
| 896 | + } | |
| 897 | + if (options?.environmentId) { | |
| 898 | + return composerTargetKey(scopeThreadRef(options.environmentId, threadKeyOrId as ThreadId)); | |
| 899 | + } | |
| 900 | + return threadKeyOrId; | |
| 901 | +} | |
| 902 | + | |
| 903 | +function composerThreadRefFromKey(threadKey: string): ScopedThreadRef | null { | |
| 904 | + return parseScopedThreadKey(threadKey); | |
| 905 | +} | |
| 906 | + | |
| 907 | +type ComposerThreadLookupState = Pick< | |
| 908 | + ComposerDraftStoreState, | |
| 909 | + "draftsByThreadKey" | "draftThreadsByThreadKey" | |
| 910 | +>; | |
| 911 | + | |
| 912 | +function normalizeComposerTarget( | |
| 913 | + state: ComposerThreadLookupState, | |
| 914 | + target: ComposerThreadTarget, | |
| 915 | +): ComposerThreadTarget | null { | |
| 916 | + if (typeof target === "string") { | |
| 917 | + const draftId = target.trim(); | |
| 918 | + return draftId.length > 0 ? DraftId.makeUnsafe(draftId) : null; | |
| 919 | + } | |
| 920 | + return target; | |
| 921 | +} | |
| 922 | + | |
| 923 | +function resolveComposerDraftKey( | |
| 924 | + state: ComposerThreadLookupState, | |
| 925 | + target: ComposerThreadTarget, | |
| 926 | +): string | null { | |
| 927 | + const normalizedTarget = normalizeComposerTarget(state, target); | |
| 928 | + if (!normalizedTarget) { | |
| 929 | + return null; | |
| 930 | + } | |
| 931 | + if (typeof normalizedTarget !== "string") { | |
| 932 | + const scopedKey = composerTargetKey(normalizedTarget); | |
| 933 | + if (state.draftsByThreadKey[scopedKey]) { | |
| 934 | + return scopedKey; | |
| 935 | + } | |
| 936 | + for (const [draftId, draftSession] of Object.entries(state.draftThreadsByThreadKey)) { | |
| 937 | + if ( | |
| 938 | + draftSession.environmentId === normalizedTarget.environmentId && | |
| 939 | + draftSession.threadId === normalizedTarget.threadId | |
| 940 | + ) { | |
| 941 | + return draftId; | |
| 942 | + } | |
| 943 | + } | |
| 944 | + return scopedKey; | |
| 945 | + } | |
| 946 | + const threadKey = composerTargetKey(normalizedTarget); | |
| 947 | + return threadKey.length > 0 ? threadKey : null; | |
| 948 | +} | |
| 949 | + | |
| 950 | +function resolveComposerThreadId( | |
| 951 | + state: ComposerThreadLookupState, | |
| 952 | + target: ComposerThreadTarget, | |
| 953 | +): ThreadId | null { | |
| 954 | + const normalizedTarget = normalizeComposerTarget(state, target); | |
| 955 | + if (!normalizedTarget) { | |
| 956 | + return null; | |
| 957 | + } | |
| 958 | + if (typeof normalizedTarget !== "string") { | |
| 959 | + return normalizedTarget.threadId; | |
| 960 | + } | |
| 961 | + return state.draftThreadsByThreadKey[normalizedTarget]?.threadId ?? null; | |
| 962 | +} | |
| 963 | + | |
| 964 | +function getComposerDraftState( | |
| 965 | + state: Pick<ComposerDraftStoreState, "draftsByThreadKey" | "draftThreadsByThreadKey">, | |
| 966 | + target: ComposerThreadTarget, | |
| 967 | +): ComposerThreadDraftState | null { | |
| 968 | + const threadKey = resolveComposerDraftKey(state, target); | |
| 969 | + if (!threadKey) { | |
| 970 | + return null; | |
| 971 | + } | |
| 972 | + return state.draftsByThreadKey[threadKey] ?? null; | |
| 973 | +} | |
| 974 | + | |
| 975 | +function isComposerThreadKeyInUse(mappings: Record<string, string>, threadKey: string): boolean { | |
| 976 | + return Object.values(mappings).includes(threadKey); | |
| 977 | +} | |
| 978 | + | |
| 979 | +function toProjectDraftSession( | |
| 980 | + draftId: DraftId, | |
| 981 | + draftSession: DraftSessionState, | |
| 982 | +): ProjectDraftSession { | |
| 983 | + return { | |
| 984 | + draftId, | |
| 985 | + ...draftSession, | |
| 986 | + }; | |
| 987 | +} | |
| 988 | + | |
| 989 | +function createDraftThreadState( | |
| 990 | + projectRef: ScopedProjectRef, | |
| 991 | + threadId: ThreadId, | |
| 992 | + logicalProjectKey: string, | |
| 993 | + existingThread: DraftThreadState | undefined, | |
| 994 | + options?: { | |
| 995 | + threadId?: ThreadId; | |
| 996 | + branch?: string | null; | |
| 997 | + worktreePath?: string | null; | |
| 998 | + createdAt?: string; | |
| 999 | + envMode?: DraftThreadEnvMode; | |
| 1000 | + runtimeMode?: RuntimeMode; | |
| 1001 | + interactionMode?: ProviderInteractionMode; | |
| 1002 | + }, | |
| 1003 | +): DraftThreadState { | |
| 1004 | + const nextWorktreePath = | |
| 1005 | + options?.worktreePath === undefined | |
| 1006 | + ? (existingThread?.worktreePath ?? null) | |
| 1007 | + : (options.worktreePath ?? null); | |
| 1008 | + return { | |
| 1009 | + threadId, | |
| 1010 | + environmentId: projectRef.environmentId, | |
| 1011 | + projectId: projectRef.projectId, | |
| 1012 | + logicalProjectKey, | |
| 1013 | + createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), | |
| 1014 | + runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, | |
| 1015 | + interactionMode: | |
| 1016 | + options?.interactionMode ?? existingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, | |
| 1017 | + branch: | |
| 1018 | + options?.branch === undefined ? (existingThread?.branch ?? null) : (options.branch ?? null), | |
| 1019 | + worktreePath: nextWorktreePath, | |
| 1020 | + envMode: | |
| 1021 | + options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), | |
| 1022 | + promotedTo: null, | |
| 1023 | + }; | |
| 1024 | +} | |
| 1025 | + | |
| 1026 | +function scopedThreadRefsEqual( | |
| 1027 | + left: ScopedThreadRef | null | undefined, | |
| 1028 | + right: ScopedThreadRef | null | undefined, | |
| 1029 | +): boolean { | |
| 1030 | + if (!left || !right) { | |
| 1031 | + return left === right; | |
| 1032 | + } | |
| 1033 | + return left.environmentId === right.environmentId && left.threadId === right.threadId; | |
| 1034 | +} | |
| 1035 | + | |
| 1036 | +function isDraftThreadPromoting(draftThread: DraftThreadState | null | undefined): boolean { | |
| 1037 | + return draftThread?.promotedTo !== null && draftThread?.promotedTo !== undefined; | |
| 1038 | +} | |
| 1039 | + | |
| 1040 | +function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThreadState): boolean { | |
| 1041 | + return ( | |
| 1042 | + !!left && | |
| 1043 | + left.threadId === right.threadId && | |
| 1044 | + left.environmentId === right.environmentId && | |
| 1045 | + left.projectId === right.projectId && | |
| 1046 | + left.logicalProjectKey === right.logicalProjectKey && | |
| 1047 | + left.createdAt === right.createdAt && | |
| 1048 | + left.runtimeMode === right.runtimeMode && | |
| 1049 | + left.interactionMode === right.interactionMode && | |
| 1050 | + left.branch === right.branch && | |
| 1051 | + left.worktreePath === right.worktreePath && | |
| 1052 | + left.envMode === right.envMode && | |
| 1053 | + scopedThreadRefsEqual(left.promotedTo, right.promotedTo) | |
| 1054 | + ); | |
| 1055 | +} | |
| 1056 | + | |
| 1057 | +function removeDraftThreadReferences( | |
| 1058 | + state: Pick< | |
| 1059 | + ComposerDraftStoreState, | |
| 1060 | + | "draftThreadsByThreadKey" | |
| 1061 | + | "draftsByThreadKey" | |
| 1062 | + | "logicalProjectDraftThreadKeyByLogicalProjectKey" | |
| 1063 | + >, | |
| 1064 | + threadKey: string, | |
| 1065 | +): Pick< | |
| 1066 | + ComposerDraftStoreState, | |
| 1067 | + | "draftThreadsByThreadKey" | |
| 1068 | + | "draftsByThreadKey" | |
| 1069 | + | "logicalProjectDraftThreadKeyByLogicalProjectKey" | |
| 1070 | +> { | |
| 1071 | + const nextLogicalMappings = Object.fromEntries( | |
| 1072 | + Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( | |
| 1073 | + ([, draftThreadKey]) => draftThreadKey !== threadKey, | |
| 1074 | + ), | |
| 1075 | + ) as Record<string, string>; | |
| 1076 | + const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } = | |
| 1077 | + state.draftThreadsByThreadKey; | |
| 1078 | + const { [threadKey]: _removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; | |
| 1079 | + return { | |
| 1080 | + draftsByThreadKey: restDraftsByThreadKey, | |
| 1081 | + draftThreadsByThreadKey: restDraftThreadsByThreadKey, | |
| 1082 | + logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, | |
| 1083 | + }; | |
| 1084 | +} | |
| 1085 | + | |
| 751 | 1086 | function normalizePersistedDraftThreads( |
| 752 | 1087 | rawDraftThreadsByThreadId: unknown, |
| 753 | − rawProjectDraftThreadIdByProjectId: unknown, | |
| 1088 | + rawProjectDraftThreadIdByProjectKey: unknown, | |
| 754 | 1089 | ): Pick< |
| 755 | 1090 | PersistedComposerDraftStoreState, |
| 756 | − "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId" | |
| 1091 | + "draftThreadsByThreadKey" | "logicalProjectDraftThreadKeyByLogicalProjectKey" | |
| 757 | 1092 | > { |
| 758 | − const draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState> = {}; | |
| 1093 | + const draftThreadsByThreadKey: Record<string, PersistedDraftThreadState> = {}; | |
| 1094 | + const environmentIdByThreadId = new Map<ThreadId, EnvironmentId>(); | |
| 1095 | + if ( | |
| 1096 | + rawProjectDraftThreadIdByProjectKey && | |
| 1097 | + typeof rawProjectDraftThreadIdByProjectKey === "object" | |
| 1098 | + ) { | |
| 1099 | + for (const [projectKey, threadId] of Object.entries( | |
| 1100 | + rawProjectDraftThreadIdByProjectKey as Record<string, unknown>, | |
| 1101 | + )) { | |
| 1102 | + if (typeof threadId !== "string" || threadId.length === 0) { | |
| 1103 | + continue; | |
| 1104 | + } | |
| 1105 | + const projectRef = parseScopedProjectKey(projectKey); | |
| 1106 | + if (!projectRef) { | |
| 1107 | + continue; | |
| 1108 | + } | |
| 1109 | + const parsedThreadRef = parseScopedThreadKey(threadId); | |
| 1110 | + if (parsedThreadRef) { | |
| 1111 | + environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId); | |
| 1112 | + continue; | |
| 1113 | + } | |
| 1114 | + environmentIdByThreadId.set(threadId as ThreadId, projectRef.environmentId); | |
| 1115 | + } | |
| 1116 | + } | |
| 759 | 1117 | if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { |
| 760 | − for (const [threadId, rawDraftThread] of Object.entries( | |
| 1118 | + for (const [threadKeyOrId, rawDraftThread] of Object.entries( | |
| 761 | 1119 | rawDraftThreadsByThreadId as Record<string, unknown>, |
| 762 | 1120 | )) { |
| 763 | − if (typeof threadId !== "string" || threadId.length === 0) { | |
| 1121 | + if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) { | |
| 764 | 1122 | continue; |
| 765 | 1123 | } |
| 766 | 1124 | if (!rawDraftThread || typeof rawDraftThread !== "object") { |
| 767 | 1125 | continue; |
| 768 | 1126 | } |
| 769 | 1127 | const candidateDraftThread = rawDraftThread as Record<string, unknown>; |
| 1128 | + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); | |
| 1129 | + const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId); | |
| 1130 | + const threadId = | |
| 1131 | + parsedThreadRef?.threadId ?? | |
| 1132 | + (typeof candidateDraftThread.threadId === "string" && | |
| 1133 | + candidateDraftThread.threadId.length > 0 | |
| 1134 | + ? (candidateDraftThread.threadId as ThreadId) | |
| 1135 | + : (threadKeyOrId as ThreadId)); | |
| 1136 | + const environmentId = | |
| 1137 | + parsedThreadRef?.environmentId ?? | |
| 1138 | + (typeof candidateDraftThread.environmentId === "string" && | |
| 1139 | + candidateDraftThread.environmentId.length > 0 | |
| 1140 | + ? (candidateDraftThread.environmentId as EnvironmentId) | |
| 1141 | + : environmentIdByThreadId.get(threadKeyOrId as ThreadId)); | |
| 770 | 1142 | const projectId = candidateDraftThread.projectId; |
| 771 | 1143 | const createdAt = candidateDraftThread.createdAt; |
| 772 | 1144 | const branch = candidateDraftThread.branch; |
| 773 | 1145 | const worktreePath = candidateDraftThread.worktreePath; |
| 774 | 1146 | const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; |
| 775 | − if (typeof projectId !== "string" || projectId.length === 0) { | |
| 1147 | + const promotedToCandidate = candidateDraftThread.promotedTo; | |
| 1148 | + const promotedToRecord = | |
| 1149 | + promotedToCandidate && typeof promotedToCandidate === "object" | |
| 1150 | + ? (promotedToCandidate as Record<string, unknown>) | |
| 1151 | + : null; | |
| 1152 | + const promotedTo = | |
| 1153 | + promotedToRecord && | |
| 1154 | + typeof promotedToRecord.environmentId === "string" && | |
| 1155 | + promotedToRecord.environmentId.length > 0 && | |
| 1156 | + typeof promotedToRecord.threadId === "string" && | |
| 1157 | + promotedToRecord.threadId.length > 0 | |
| 1158 | + ? scopeThreadRef( | |
| 1159 | + promotedToRecord.environmentId as EnvironmentId, | |
| 1160 | + promotedToRecord.threadId as ThreadId, | |
| 1161 | + ) | |
| 1162 | + : null; | |
| 1163 | + if (typeof projectId !== "string" || projectId.length === 0 || environmentId === undefined) { | |
| 776 | 1164 | continue; |
| 777 | 1165 | } |
| 778 | − draftThreadsByThreadId[threadId as ThreadId] = { | |
| 1166 | + const normalizedEnvironmentId = environmentId as EnvironmentId; | |
| 1167 | + draftThreadsByThreadKey[threadKey] = { | |
| 1168 | + threadId, | |
| 1169 | + environmentId: normalizedEnvironmentId, | |
| 779 | 1170 | projectId: projectId as ProjectId, |
| 1171 | + logicalProjectKey: | |
| 1172 | + typeof candidateDraftThread.logicalProjectKey === "string" && | |
| 1173 | + candidateDraftThread.logicalProjectKey.length > 0 | |
| 1174 | + ? candidateDraftThread.logicalProjectKey | |
| 1175 | + : parsedThreadRef | |
| 1176 | + ? projectDraftKey(scopeProjectRef(normalizedEnvironmentId, projectId as ProjectId)) | |
| 1177 | + : threadKeyOrId, | |
| 780 | 1178 | createdAt: |
| 781 | 1179 | typeof createdAt === "string" && createdAt.length > 0 |
| 782 | 1180 | ? createdAt |
| @@ -794,59 +1192,97 @@ function normalizePersistedDraftThreads( · 11 unmodified lines | ||
| 794 | 1192 | branch: typeof branch === "string" ? branch : null, |
| 795 | 1193 | worktreePath: normalizedWorktreePath, |
| 796 | 1194 | envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), |
| 1195 | + promotedTo, | |
| 797 | 1196 | }; |
| 798 | 1197 | } |
| 799 | 1198 | } |
| 800 | 1199 | |
| 801 | − const projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {}; | |
| 1200 | + const logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> = {}; | |
| 802 | 1201 | if ( |
| 803 | − rawProjectDraftThreadIdByProjectId && | |
| 804 | − typeof rawProjectDraftThreadIdByProjectId === "object" | |
| 1202 | + rawProjectDraftThreadIdByProjectKey && | |
| 1203 | + typeof rawProjectDraftThreadIdByProjectKey === "object" | |
| 805 | 1204 | ) { |
| 806 | − for (const [projectId, threadId] of Object.entries( | |
| 807 | − rawProjectDraftThreadIdByProjectId as Record<string, unknown>, | |
| 1205 | + for (const [logicalProjectKey, threadKeyOrId] of Object.entries( | |
| 1206 | + rawProjectDraftThreadIdByProjectKey as Record<string, unknown>, | |
| 808 | 1207 | )) { |
| 809 | − if ( | |
| 810 | − typeof projectId === "string" && | |
| 811 | − projectId.length > 0 && | |
| 812 | − typeof threadId === "string" && | |
| 813 | − threadId.length > 0 | |
| 814 | − ) { | |
| 815 | − projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId; | |
| 816 | − if (!draftThreadsByThreadId[threadId as ThreadId]) { | |
| 817 | − draftThreadsByThreadId[threadId as ThreadId] = { | |
| 818 | − projectId: projectId as ProjectId, | |
| 819 | − createdAt: new Date().toISOString(), | |
| 820 | − runtimeMode: DEFAULT_RUNTIME_MODE, | |
| 821 | − interactionMode: DEFAULT_INTERACTION_MODE, | |
| 822 | − branch: null, | |
| 823 | − worktreePath: null, | |
| 824 | − envMode: "local", | |
| 825 | − }; | |
| 826 | − } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) { | |
| 827 | − draftThreadsByThreadId[threadId as ThreadId] = { | |
| 828 | − ...draftThreadsByThreadId[threadId as ThreadId]!, | |
| 829 | − projectId: projectId as ProjectId, | |
| 1208 | + if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) { | |
| 1209 | + continue; | |
| 1210 | + } | |
| 1211 | + const projectRef = parseScopedProjectKey(logicalProjectKey); | |
| 1212 | + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); | |
| 1213 | + const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId); | |
| 1214 | + logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey; | |
| 1215 | + if (parsedThreadRef) { | |
| 1216 | + environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId); | |
| 1217 | + } | |
| 1218 | + if (!projectRef) { | |
| 1219 | + const existingDraftThread = draftThreadsByThreadKey[threadKey]; | |
| 1220 | + if (existingDraftThread && !existingDraftThread.logicalProjectKey) { | |
| 1221 | + draftThreadsByThreadKey[threadKey] = { | |
| 1222 | + ...existingDraftThread, | |
| 1223 | + logicalProjectKey, | |
| 830 | 1224 | }; |
| 831 | 1225 | } |
| 1226 | + continue; | |
| 1227 | + } | |
| 1228 | + if (!draftThreadsByThreadKey[threadKey]) { | |
| 1229 | + draftThreadsByThreadKey[threadKey] = { | |
| 1230 | + threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId), | |
| 1231 | + environmentId: projectRef.environmentId, | |
| 1232 | + projectId: projectRef.projectId, | |
| 1233 | + logicalProjectKey, | |
| 1234 | + createdAt: new Date().toISOString(), | |
| 1235 | + runtimeMode: DEFAULT_RUNTIME_MODE, | |
| 1236 | + interactionMode: DEFAULT_INTERACTION_MODE, | |
| 1237 | + branch: null, | |
| 1238 | + worktreePath: null, | |
| 1239 | + envMode: "local", | |
| 1240 | + promotedTo: null, | |
| 1241 | + }; | |
| 1242 | + } else if ( | |
| 1243 | + draftThreadsByThreadKey[threadKey]?.projectId !== projectRef.projectId || | |
| 1244 | + draftThreadsByThreadKey[threadKey]?.environmentId !== projectRef.environmentId | |
| 1245 | + ) { | |
| 1246 | + draftThreadsByThreadKey[threadKey] = { | |
| 1247 | + ...draftThreadsByThreadKey[threadKey]!, | |
| 1248 | + threadId: draftThreadsByThreadKey[threadKey]!.threadId, | |
| 1249 | + environmentId: projectRef.environmentId, | |
| 1250 | + projectId: projectRef.projectId, | |
| 1251 | + logicalProjectKey, | |
| 1252 | + }; | |
| 832 | 1253 | } |
| 833 | 1254 | } |
| 834 | 1255 | } |
| 835 | 1256 | |
| 836 | − return { draftThreadsByThreadId, projectDraftThreadIdByProjectId }; | |
| 1257 | + return { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey }; | |
| 837 | 1258 | } |
| 838 | 1259 | |
| 839 | 1260 | function normalizePersistedDraftsByThreadId( |
| 840 | 1261 | rawDraftMap: unknown, |
| 841 | −): PersistedComposerDraftStoreState["draftsByThreadId"] { | |
| 1262 | + draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"], | |
| 1263 | +): PersistedComposerDraftStoreState["draftsByThreadKey"] { | |
| 842 | 1264 | if (!rawDraftMap || typeof rawDraftMap !== "object") { |
| 843 | 1265 | return {}; |
| 844 | 1266 | } |
| 845 | 1267 | |
| 846 | − const nextDraftsByThreadId: DeepMutable<PersistedComposerDraftStoreState["draftsByThreadId"]> = | |
| 1268 | + const environmentIdByThreadId = new Map<ThreadId, EnvironmentId>(); | |
| 1269 | + for (const [threadKey, draftThread] of Object.entries(draftThreadsByThreadKey)) { | |
| 1270 | + const parsedThreadRef = composerThreadRefFromKey(threadKey); | |
| 1271 | + if (!parsedThreadRef) { | |
| 1272 | + continue; | |
| 1273 | + } | |
| 1274 | + environmentIdByThreadId.set( | |
| 1275 | + parsedThreadRef.threadId, | |
| 1276 | + draftThread.environmentId as EnvironmentId, | |
| 1277 | + ); | |
| 1278 | + } | |
| 1279 | + | |
| 1280 | + const nextDraftsByThreadKey: DeepMutable<PersistedComposerDraftStoreState["draftsByThreadKey"]> = | |
| 847 | 1281 | {}; |
| 848 | − for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) { | |
| 849 | − if (typeof threadId !== "string" || threadId.length === 0) { | |
| 1282 | + for (const [threadKeyOrId, draftValue] of Object.entries( | |
| 1283 | + rawDraftMap as Record<string, unknown>, | |
| 1284 | + )) { | |
| 1285 | + if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) { | |
| 850 | 1286 | continue; |
| 851 | 1287 | } |
| 852 | 1288 | if (!draftValue || typeof draftValue !== "object") { |
| @@ -937,7 +1373,19 @@ function normalizePersistedDraftsByThreadId( · 84 unmodified lines | ||
| 937 | 1373 | ) { |
| 938 | 1374 | continue; |
| 939 | 1375 | } |
| 940 | − nextDraftsByThreadId[threadId as ThreadId] = { | |
| 1376 | + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); | |
| 1377 | + const normalizedThreadKey = | |
| 1378 | + parsedThreadRef !== null | |
| 1379 | + ? normalizeLegacyComposerStorageKey(threadKeyOrId) | |
| 1380 | + : draftThreadsByThreadKey[threadKeyOrId] !== undefined | |
| 1381 | + ? threadKeyOrId | |
| 1382 | + : (() => { | |
| 1383 | + const environmentId = environmentIdByThreadId.get(threadKeyOrId as ThreadId); | |
| 1384 | + return environmentId | |
| 1385 | + ? normalizeLegacyComposerStorageKey(threadKeyOrId, { environmentId }) | |
| 1386 | + : threadKeyOrId; | |
| 1387 | + })(); | |
| 1388 | + nextDraftsByThreadKey[normalizedThreadKey] = { | |
| 941 | 1389 | prompt, |
| 942 | 1390 | attachments, |
| 943 | 1391 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), |
| @@ -947,7 +1395,7 @@ function normalizePersistedDraftsByThreadId( · 3 unmodified lines | ||
| 947 | 1395 | }; |
| 948 | 1396 | } |
| 949 | 1397 | |
| 950 | − return nextDraftsByThreadId; | |
| 1398 | + return nextDraftsByThreadKey; | |
| 951 | 1399 | } |
| 952 | 1400 | |
| 953 | 1401 | function migratePersistedComposerDraftStoreState( |
| @@ -957,9 +1405,14 @@ function migratePersistedComposerDraftStoreState( · 3 unmodified lines | ||
| 957 | 1405 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 958 | 1406 | } |
| 959 | 1407 | const candidate = persistedState as LegacyPersistedComposerDraftStoreState; |
| 960 | − const rawDraftMap = candidate.draftsByThreadId; | |
| 961 | − const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; | |
| 962 | − const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; | |
| 1408 | + const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId; | |
| 1409 | + const rawDraftThreadsByThreadId = | |
| 1410 | + candidate.draftThreadsByThreadKey ?? candidate.draftThreadsByThreadId; | |
| 1411 | + const rawProjectDraftThreadIdByProjectKey = | |
| 1412 | + candidate.logicalProjectDraftThreadKeyByLogicalProjectKey ?? | |
| 1413 | + candidate.projectDraftThreadKeyByProjectKey ?? | |
| 1414 | + candidate.projectDraftThreadIdByProjectKey ?? | |
| 1415 | + candidate.projectDraftThreadIdByProjectId; | |
| 963 | 1416 | |
| 964 | 1417 | // Migrate sticky state from v2 (dual) to v3 (consolidated) |
| 965 | 1418 | const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; |
| @@ -982,13 +1435,16 @@ function migratePersistedComposerDraftStoreState( · 16 unmodified lines | ||
| 982 | 1435 | ); |
| 983 | 1436 | const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null; |
| 984 | 1437 | |
| 985 | − const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = | |
| 986 | − normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); | |
| 987 | − const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap); | |
| 1438 | + const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = | |
| 1439 | + normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey); | |
| 1440 | + const draftsByThreadKey = normalizePersistedDraftsByThreadId( | |
| 1441 | + rawDraftMap, | |
| 1442 | + draftThreadsByThreadKey, | |
| 1443 | + ); | |
| 988 | 1444 | return { |
| 989 | − draftsByThreadId, | |
| 990 | − draftThreadsByThreadId, | |
| 991 | − projectDraftThreadIdByProjectId, | |
| 1445 | + draftsByThreadKey, | |
| 1446 | + draftThreadsByThreadKey, | |
| 1447 | + logicalProjectDraftThreadKeyByLogicalProjectKey, | |
| 992 | 1448 | stickyModelSelectionByProvider, |
| 993 | 1449 | stickyActiveProvider, |
| 994 | 1450 | }; |
| @@ -997,11 +1453,11 @@ function migratePersistedComposerDraftStoreState( · 2 unmodified lines | ||
| 997 | 1453 | function partializeComposerDraftStoreState( |
| 998 | 1454 | state: ComposerDraftStoreState, |
| 999 | 1455 | ): PersistedComposerDraftStoreState { |
| 1000 | − const persistedDraftsByThreadId: DeepMutable< | |
| 1001 | − PersistedComposerDraftStoreState["draftsByThreadId"] | |
| 1456 | + const persistedDraftsByThreadKey: DeepMutable< | |
| 1457 | + PersistedComposerDraftStoreState["draftsByThreadKey"] | |
| 1002 | 1458 | > = {}; |
| 1003 | − for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { | |
| 1004 | − if (typeof threadId !== "string" || threadId.length === 0) { | |
| 1459 | + for (const [threadKey, draft] of Object.entries(state.draftsByThreadKey)) { | |
| 1460 | + if (typeof threadKey !== "string" || threadKey.length === 0) { | |
| 1005 | 1461 | continue; |
| 1006 | 1462 | } |
| 1007 | 1463 | const hasModelData = |
| @@ -1041,12 +1497,13 @@ function partializeComposerDraftStoreState( · 33 unmodified lines | ||
| 1041 | 1497 | ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), |
| 1042 | 1498 | ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), |
| 1043 | 1499 | }; |
| 1044 | − persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft; | |
| 1500 | + persistedDraftsByThreadKey[threadKey] = persistedDraft; | |
| 1045 | 1501 | } |
| 1046 | 1502 | return { |
| 1047 | − draftsByThreadId: persistedDraftsByThreadId, | |
| 1048 | − draftThreadsByThreadId: state.draftThreadsByThreadId, | |
| 1049 | − projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, | |
| 1503 | + draftsByThreadKey: persistedDraftsByThreadKey, | |
| 1504 | + draftThreadsByThreadKey: state.draftThreadsByThreadKey, | |
| 1505 | + logicalProjectDraftThreadKeyByLogicalProjectKey: | |
| 1506 | + state.logicalProjectDraftThreadKeyByLogicalProjectKey, | |
| 1050 | 1507 | stickyModelSelectionByProvider: state.stickyModelSelectionByProvider, |
| 1051 | 1508 | stickyActiveProvider: state.stickyActiveProvider, |
| 1052 | 1509 | }; |
| @@ -1059,10 +1516,14 @@ function normalizeCurrentPersistedComposerDraftStoreState( · 6 unmodified lines | ||
| 1059 | 1516 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 1060 | 1517 | } |
| 1061 | 1518 | const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState; |
| 1062 | − const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = | |
| 1519 | + const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = | |
| 1063 | 1520 | normalizePersistedDraftThreads( |
| 1064 | − normalizedPersistedState.draftThreadsByThreadId, | |
| 1065 | − normalizedPersistedState.projectDraftThreadIdByProjectId, | |
| 1521 | + normalizedPersistedState.draftThreadsByThreadKey ?? | |
| 1522 | + normalizedPersistedState.draftThreadsByThreadId, | |
| 1523 | + normalizedPersistedState.logicalProjectDraftThreadKeyByLogicalProjectKey ?? | |
| 1524 | + normalizedPersistedState.projectDraftThreadKeyByProjectKey ?? | |
| 1525 | + normalizedPersistedState.projectDraftThreadIdByProjectKey ?? | |
| 1526 | + normalizedPersistedState.projectDraftThreadIdByProjectId, | |
| 1066 | 1527 | ); |
| 1067 | 1528 | |
| 1068 | 1529 | // Handle both v3 (modelSelectionByProvider) and v2/legacy formats |
| @@ -1105,16 +1566,19 @@ function normalizeCurrentPersistedComposerDraftStoreState( · 36 unmodified lines | ||
| 1105 | 1566 | } |
| 1106 | 1567 | |
| 1107 | 1568 | return { |
| 1108 | − draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId), | |
| 1109 | − draftThreadsByThreadId, | |
| 1110 | − projectDraftThreadIdByProjectId, | |
| 1569 | + draftsByThreadKey: normalizePersistedDraftsByThreadId( | |
| 1570 | + normalizedPersistedState.draftsByThreadKey ?? normalizedPersistedState.draftsByThreadId, | |
| 1571 | + draftThreadsByThreadKey, | |
| 1572 | + ), | |
| 1573 | + draftThreadsByThreadKey, | |
| 1574 | + logicalProjectDraftThreadKeyByLogicalProjectKey, | |
| 1111 | 1575 | stickyModelSelectionByProvider, |
| 1112 | 1576 | stickyActiveProvider, |
| 1113 | 1577 | }; |
| 1114 | 1578 | } |
| 1115 | 1579 | |
| 1116 | −function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { | |
| 1117 | − if (threadId.length === 0) { | |
| 1580 | +function readPersistedAttachmentIdsFromStorage(threadKey: string): string[] { | |
| 1581 | + if (threadKey.length === 0) { | |
| 1118 | 1582 | return []; |
| 1119 | 1583 | } |
| 1120 | 1584 | try { |
| @@ -1125,7 +1589,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { · 4 unmodified lines | ||
| 1125 | 1589 | if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) { |
| 1126 | 1590 | return []; |
| 1127 | 1591 | } |
| 1128 | − return (persisted.state.draftsByThreadId[threadId]?.attachments ?? []).map( | |
| 1592 | + return (persisted.state.draftsByThreadKey[threadKey]?.attachments ?? []).map( | |
| 1129 | 1593 | (attachment) => attachment.id, |
| 1130 | 1594 | ); |
| 1131 | 1595 | } catch { |
| @@ -1134,7 +1598,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { · 2 unmodified lines | ||
| 1134 | 1598 | } |
| 1135 | 1599 | |
| 1136 | 1600 | function verifyPersistedAttachments( |
| 1137 | − threadId: ThreadId, | |
| 1601 | + threadKey: string, | |
| 1138 | 1602 | attachments: PersistedComposerImageAttachment[], |
| 1139 | 1603 | set: ( |
| 1140 | 1604 | partial: |
| @@ -1149,12 +1613,12 @@ function verifyPersistedAttachments( · 8 unmodified lines | ||
| 1149 | 1613 | let persistedIdSet = new Set<string>(); |
| 1150 | 1614 | try { |
| 1151 | 1615 | composerDebouncedStorage.flush(); |
| 1152 | − persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId)); | |
| 1616 | + persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadKey)); | |
| 1153 | 1617 | } catch { |
| 1154 | 1618 | persistedIdSet = new Set(); |
| 1155 | 1619 | } |
| 1156 | 1620 | set((state) => { |
| 1157 | − const current = state.draftsByThreadId[threadId]; | |
| 1621 | + const current = state.draftsByThreadKey[threadKey]; | |
| 1158 | 1622 | if (!current) { |
| 1159 | 1623 | return state; |
| 1160 | 1624 | } |
| @@ -1170,13 +1634,13 @@ function verifyPersistedAttachments( · 9 unmodified lines | ||
| 1170 | 1634 | persistedAttachments, |
| 1171 | 1635 | nonPersistedImageIds, |
| 1172 | 1636 | }; |
| 1173 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1637 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 1174 | 1638 | if (shouldRemoveDraft(nextDraft)) { |
| 1175 | − delete nextDraftsByThreadId[threadId]; | |
| 1639 | + delete nextDraftsByThreadKey[threadKey]; | |
| 1176 | 1640 | } else { |
| 1177 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 1641 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 1178 | 1642 | } |
| 1179 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 1643 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 1180 | 1644 | }); |
| 1181 | 1645 | } |
| 1182 | 1646 | |
| @@ -1258,879 +1722,980 @@ function toHydratedThreadDraft( · 75 unmodified lines | ||
| 1258 | 1722 | }; |
| 1259 | 1723 | } |
| 1260 | 1724 | |
| 1261 | −export const useComposerDraftStore = create<ComposerDraftStoreState>()( | |
| 1725 | +function toHydratedDraftThreadState( | |
| 1726 | + persistedDraftThread: PersistedDraftThreadState, | |
| 1727 | +): DraftThreadState { | |
| 1728 | + return { | |
| 1729 | + threadId: persistedDraftThread.threadId, | |
| 1730 | + environmentId: persistedDraftThread.environmentId as EnvironmentId, | |
| 1731 | + projectId: persistedDraftThread.projectId, | |
| 1732 | + logicalProjectKey: | |
| 1733 | + persistedDraftThread.logicalProjectKey ?? | |
| 1734 | + projectDraftKey( | |
| 1735 | + scopeProjectRef( | |
| 1736 | + persistedDraftThread.environmentId as EnvironmentId, | |
| 1737 | + persistedDraftThread.projectId, | |
| 1738 | + ), | |
| 1739 | + ), | |
| 1740 | + createdAt: persistedDraftThread.createdAt, | |
| 1741 | + runtimeMode: persistedDraftThread.runtimeMode, | |
| 1742 | + interactionMode: persistedDraftThread.interactionMode, | |
| 1743 | + branch: persistedDraftThread.branch, | |
| 1744 | + worktreePath: persistedDraftThread.worktreePath, | |
| 1745 | + envMode: persistedDraftThread.envMode, | |
| 1746 | + promotedTo: persistedDraftThread.promotedTo | |
| 1747 | + ? scopeThreadRef( | |
| 1748 | + persistedDraftThread.promotedTo.environmentId as EnvironmentId, | |
| 1749 | + persistedDraftThread.promotedTo.threadId as ThreadId, | |
| 1750 | + ) | |
| 1751 | + : null, | |
| 1752 | + }; | |
| 1753 | +} | |
| 1754 | + | |
| 1755 | +const composerDraftStore = create<ComposerDraftStoreState>()( | |
| 1262 | 1756 | persist( |
| 1263 | − (set, get) => ({ | |
| 1264 | − draftsByThreadId: {}, | |
| 1265 | − draftThreadsByThreadId: {}, | |
| 1266 | − projectDraftThreadIdByProjectId: {}, | |
| 1267 | − stickyModelSelectionByProvider: {}, | |
| 1268 | − stickyActiveProvider: null, | |
| 1269 | − getDraftThreadByProjectId: (projectId) => { | |
| 1270 | − if (projectId.length === 0) { | |
| 1271 | − return null; | |
| 1272 | − } | |
| 1273 | − const threadId = get().projectDraftThreadIdByProjectId[projectId]; | |
| 1274 | − if (!threadId) { | |
| 1275 | − return null; | |
| 1276 | − } | |
| 1277 | − const draftThread = get().draftThreadsByThreadId[threadId]; | |
| 1278 | − if (!draftThread || draftThread.projectId !== projectId) { | |
| 1279 | − return null; | |
| 1280 | − } | |
| 1281 | − return { | |
| 1282 | − threadId, | |
| 1283 | − ...draftThread, | |
| 1284 | − }; | |
| 1285 | − }, | |
| 1286 | − getDraftThread: (threadId) => { | |
| 1287 | − if (threadId.length === 0) { | |
| 1288 | − return null; | |
| 1289 | − } | |
| 1290 | − return get().draftThreadsByThreadId[threadId] ?? null; | |
| 1291 | − }, | |
| 1292 | − setProjectDraftThreadId: (projectId, threadId, options) => { | |
| 1293 | − if (projectId.length === 0 || threadId.length === 0) { | |
| 1294 | − return; | |
| 1295 | − } | |
| 1296 | − set((state) => { | |
| 1297 | − const existingThread = state.draftThreadsByThreadId[threadId]; | |
| 1298 | − const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId]; | |
| 1299 | − const nextWorktreePath = | |
| 1300 | − options?.worktreePath === undefined | |
| 1301 | − ? (existingThread?.worktreePath ?? null) | |
| 1302 | − : (options.worktreePath ?? null); | |
| 1303 | − const nextDraftThread: DraftThreadState = { | |
| 1304 | − projectId, | |
| 1305 | − createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), | |
| 1306 | − runtimeMode: | |
| 1307 | − options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, | |
| 1308 | − interactionMode: | |
| 1309 | − options?.interactionMode ?? | |
| 1310 | − existingThread?.interactionMode ?? | |
| 1311 | − DEFAULT_INTERACTION_MODE, | |
| 1312 | − branch: | |
| 1313 | − options?.branch === undefined | |
| 1314 | − ? (existingThread?.branch ?? null) | |
| 1315 | − : (options.branch ?? null), | |
| 1316 | − worktreePath: nextWorktreePath, | |
| 1317 | − envMode: | |
| 1318 | − options?.envMode ?? | |
| 1319 | − (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), | |
| 1320 | − }; | |
| 1321 | − const hasSameProjectMapping = previousThreadIdForProject === threadId; | |
| 1322 | − const hasSameDraftThread = | |
| 1323 | − existingThread && | |
| 1324 | − existingThread.projectId === nextDraftThread.projectId && | |
| 1325 | − existingThread.createdAt === nextDraftThread.createdAt && | |
| 1326 | − existingThread.runtimeMode === nextDraftThread.runtimeMode && | |
| 1327 | − existingThread.interactionMode === nextDraftThread.interactionMode && | |
| 1328 | − existingThread.branch === nextDraftThread.branch && | |
| 1329 | − existingThread.worktreePath === nextDraftThread.worktreePath && | |
| 1330 | − existingThread.envMode === nextDraftThread.envMode; | |
| 1331 | − if (hasSameProjectMapping && hasSameDraftThread) { | |
| 1332 | − return state; | |
| 1333 | − } | |
| 1334 | − const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = { | |
| 1335 | − ...state.projectDraftThreadIdByProjectId, | |
| 1336 | − [projectId]: threadId, | |
| 1337 | − }; | |
| 1338 | − const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = { | |
| 1339 | − ...state.draftThreadsByThreadId, | |
| 1340 | − [threadId]: nextDraftThread, | |
| 1341 | − }; | |
| 1342 | − let nextDraftsByThreadId = state.draftsByThreadId; | |
| 1343 | − if ( | |
| 1344 | − previousThreadIdForProject && | |
| 1345 | − previousThreadIdForProject !== threadId && | |
| 1346 | − !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject) | |
| 1347 | − ) { | |
| 1348 | − delete nextDraftThreadsByThreadId[previousThreadIdForProject]; | |
| 1349 | − if (state.draftsByThreadId[previousThreadIdForProject] !== undefined) { | |
| 1350 | − nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1351 | − delete nextDraftsByThreadId[previousThreadIdForProject]; | |
| 1352 | − } | |
| 1353 | − } | |
| 1354 | − return { | |
| 1355 | − draftsByThreadId: nextDraftsByThreadId, | |
| 1356 | − draftThreadsByThreadId: nextDraftThreadsByThreadId, | |
| 1357 | − projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, | |
| 1358 | − }; | |
| 1359 | − }); | |
| 1360 | − }, | |
| 1361 | − setDraftThreadContext: (threadId, options) => { | |
| 1362 | − if (threadId.length === 0) { | |
| 1363 | − return; | |
| 1364 | − } | |
| 1365 | − set((state) => { | |
| 1366 | − const existing = state.draftThreadsByThreadId[threadId]; | |
| 1367 | − if (!existing) { | |
| 1368 | − return state; | |
| 1757 | + (setBase, get) => { | |
| 1758 | + const set = setBase; | |
| 1759 | + | |
| 1760 | + return { | |
| 1761 | + draftsByThreadKey: {}, | |
| 1762 | + draftThreadsByThreadKey: {}, | |
| 1763 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 1764 | + stickyModelSelectionByProvider: {}, | |
| 1765 | + stickyActiveProvider: null, | |
| 1766 | + getComposerDraft: (target) => getComposerDraftState(get(), target), | |
| 1767 | + getDraftThreadByLogicalProjectKey: (logicalProjectKey) => { | |
| 1768 | + return get().getDraftSessionByLogicalProjectKey(logicalProjectKey); | |
| 1769 | + }, | |
| 1770 | + getDraftSessionByLogicalProjectKey: (logicalProjectKey) => { | |
| 1771 | + const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey); | |
| 1772 | + if (normalizedLogicalProjectKey.length === 0) { | |
| 1773 | + return null; | |
| 1369 | 1774 | } |
| 1370 | − const nextProjectId = options.projectId ?? existing.projectId; | |
| 1371 | − if (nextProjectId.length === 0) { | |
| 1372 | − return state; | |
| 1775 | + const draftId = | |
| 1776 | + get().logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey]; | |
| 1777 | + if (!draftId) { | |
| 1778 | + return null; | |
| 1373 | 1779 | } |
| 1374 | − const nextWorktreePath = | |
| 1375 | − options.worktreePath === undefined | |
| 1376 | − ? existing.worktreePath | |
| 1377 | − : (options.worktreePath ?? null); | |
| 1378 | − const nextDraftThread: DraftThreadState = { | |
| 1379 | − projectId: nextProjectId, | |
| 1380 | − createdAt: | |
| 1381 | − options.createdAt === undefined | |
| 1382 | − ? existing.createdAt | |
| 1383 | − : options.createdAt || existing.createdAt, | |
| 1384 | − runtimeMode: options.runtimeMode ?? existing.runtimeMode, | |
| 1385 | − interactionMode: options.interactionMode ?? existing.interactionMode, | |
| 1386 | − branch: options.branch === undefined ? existing.branch : (options.branch ?? null), | |
| 1387 | − worktreePath: nextWorktreePath, | |
| 1388 | − envMode: | |
| 1389 | − options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), | |
| 1390 | − }; | |
| 1391 | − const isUnchanged = | |
| 1392 | − nextDraftThread.projectId === existing.projectId && | |
| 1393 | − nextDraftThread.createdAt === existing.createdAt && | |
| 1394 | − nextDraftThread.runtimeMode === existing.runtimeMode && | |
| 1395 | − nextDraftThread.interactionMode === existing.interactionMode && | |
| 1396 | − nextDraftThread.branch === existing.branch && | |
| 1397 | − nextDraftThread.worktreePath === existing.worktreePath && | |
| 1398 | − nextDraftThread.envMode === existing.envMode; | |
| 1399 | − if (isUnchanged) { | |
| 1400 | − return state; | |
| 1780 | + const draftThread = get().draftThreadsByThreadKey[draftId]; | |
| 1781 | + if (!draftThread || isDraftThreadPromoting(draftThread)) { | |
| 1782 | + return null; | |
| 1401 | 1783 | } |
| 1402 | − const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = { | |
| 1403 | − ...state.projectDraftThreadIdByProjectId, | |
| 1404 | − [nextProjectId]: threadId, | |
| 1405 | − }; | |
| 1406 | − if (existing.projectId !== nextProjectId) { | |
| 1407 | − if (nextProjectDraftThreadIdByProjectId[existing.projectId] === threadId) { | |
| 1408 | − delete nextProjectDraftThreadIdByProjectId[existing.projectId]; | |
| 1784 | + return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread); | |
| 1785 | + }, | |
| 1786 | + getDraftThreadByProjectRef: (projectRef) => { | |
| 1787 | + return get().getDraftSessionByProjectRef(projectRef); | |
| 1788 | + }, | |
| 1789 | + getDraftSessionByProjectRef: (projectRef) => { | |
| 1790 | + for (const [draftId, draftThread] of Object.entries(get().draftThreadsByThreadKey)) { | |
| 1791 | + if (isDraftThreadPromoting(draftThread)) { | |
| 1792 | + continue; | |
| 1409 | 1793 | } |
| 1410 | − } | |
| 1411 | − return { | |
| 1412 | − draftThreadsByThreadId: { | |
| 1413 | − ...state.draftThreadsByThreadId, | |
| 1414 | − [threadId]: nextDraftThread, | |
| 1415 | − }, | |
| 1416 | − projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, | |
| 1417 | − }; | |
| 1418 | − }); | |
| 1419 | − }, | |
| 1420 | − clearProjectDraftThreadId: (projectId) => { | |
| 1421 | − if (projectId.length === 0) { | |
| 1422 | − return; | |
| 1423 | − } | |
| 1424 | − set((state) => { | |
| 1425 | − const threadId = state.projectDraftThreadIdByProjectId[projectId]; | |
| 1426 | − if (threadId === undefined) { | |
| 1427 | − return state; | |
| 1428 | − } | |
| 1429 | − const { [projectId]: _removed, ...restProjectMappingsRaw } = | |
| 1430 | − state.projectDraftThreadIdByProjectId; | |
| 1431 | − const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>; | |
| 1432 | − const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = { | |
| 1433 | − ...state.draftThreadsByThreadId, | |
| 1434 | − }; | |
| 1435 | − let nextDraftsByThreadId = state.draftsByThreadId; | |
| 1436 | − if (!Object.values(restProjectMappings).includes(threadId)) { | |
| 1437 | − delete nextDraftThreadsByThreadId[threadId]; | |
| 1438 | − if (state.draftsByThreadId[threadId] !== undefined) { | |
| 1439 | − nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1440 | − delete nextDraftsByThreadId[threadId]; | |
| 1794 | + if ( | |
| 1795 | + draftThread.projectId === projectRef.projectId && | |
| 1796 | + draftThread.environmentId === projectRef.environmentId | |
| 1797 | + ) { | |
| 1798 | + return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread); | |
| 1441 | 1799 | } |
| 1442 | 1800 | } |
| 1443 | − return { | |
| 1444 | − draftsByThreadId: nextDraftsByThreadId, | |
| 1445 | − draftThreadsByThreadId: nextDraftThreadsByThreadId, | |
| 1446 | − projectDraftThreadIdByProjectId: restProjectMappings, | |
| 1447 | − }; | |
| 1448 | − }); | |
| 1449 | − }, | |
| 1450 | − clearProjectDraftThreadById: (projectId, threadId) => { | |
| 1451 | − if (projectId.length === 0 || threadId.length === 0) { | |
| 1452 | − return; | |
| 1453 | − } | |
| 1454 | − set((state) => { | |
| 1455 | − if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) { | |
| 1456 | − return state; | |
| 1457 | − } | |
| 1458 | − const { [projectId]: _removed, ...restProjectMappingsRaw } = | |
| 1459 | − state.projectDraftThreadIdByProjectId; | |
| 1460 | − const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>; | |
| 1461 | − const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = { | |
| 1462 | − ...state.draftThreadsByThreadId, | |
| 1463 | − }; | |
| 1464 | − let nextDraftsByThreadId = state.draftsByThreadId; | |
| 1465 | − if (!Object.values(restProjectMappings).includes(threadId)) { | |
| 1466 | − delete nextDraftThreadsByThreadId[threadId]; | |
| 1467 | − if (state.draftsByThreadId[threadId] !== undefined) { | |
| 1468 | − nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1469 | − delete nextDraftsByThreadId[threadId]; | |
| 1801 | + return null; | |
| 1802 | + }, | |
| 1803 | + getDraftSession: (draftId) => get().draftThreadsByThreadKey[draftId] ?? null, | |
| 1804 | + getDraftSessionByRef: (threadRef) => { | |
| 1805 | + for (const draftSession of Object.values(get().draftThreadsByThreadKey)) { | |
| 1806 | + if ( | |
| 1807 | + draftSession.environmentId === threadRef.environmentId && | |
| 1808 | + draftSession.threadId === threadRef.threadId | |
| 1809 | + ) { | |
| 1810 | + return draftSession; | |
| 1470 | 1811 | } |
| 1471 | 1812 | } |
| 1472 | − return { | |
| 1473 | − draftsByThreadId: nextDraftsByThreadId, | |
| 1474 | − draftThreadsByThreadId: nextDraftThreadsByThreadId, | |
| 1475 | − projectDraftThreadIdByProjectId: restProjectMappings, | |
| 1476 | − }; | |
| 1477 | − }); | |
| 1478 | − }, | |
| 1479 | − clearDraftThread: (threadId) => { | |
| 1480 | − if (threadId.length === 0) { | |
| 1481 | − return; | |
| 1482 | − } | |
| 1483 | − const existing = get().draftsByThreadId[threadId]; | |
| 1484 | − if (existing) { | |
| 1485 | − for (const image of existing.images) { | |
| 1486 | − revokeObjectPreviewUrl(image.previewUrl); | |
| 1487 | − } | |
| 1488 | − } | |
| 1489 | − set((state) => { | |
| 1490 | − const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; | |
| 1491 | − const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( | |
| 1492 | − threadId, | |
| 1493 | − ); | |
| 1494 | − const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; | |
| 1495 | − if (!hasDraftThread && !hasProjectMapping && !hasComposerDraft) { | |
| 1496 | − return state; | |
| 1497 | − } | |
| 1498 | − const nextProjectDraftThreadIdByProjectId = Object.fromEntries( | |
| 1499 | − Object.entries(state.projectDraftThreadIdByProjectId).filter( | |
| 1500 | − ([, draftThreadId]) => draftThreadId !== threadId, | |
| 1501 | − ), | |
| 1502 | − ) as Record<ProjectId, ThreadId>; | |
| 1503 | − const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = | |
| 1504 | − state.draftThreadsByThreadId; | |
| 1505 | − const { [threadId]: _removedComposerDraft, ...restDraftsByThreadId } = | |
| 1506 | − state.draftsByThreadId; | |
| 1507 | − return { | |
| 1508 | − draftsByThreadId: restDraftsByThreadId, | |
| 1509 | − draftThreadsByThreadId: restDraftThreadsByThreadId, | |
| 1510 | − projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, | |
| 1511 | − }; | |
| 1512 | − }); | |
| 1513 | − }, | |
| 1514 | − setStickyModelSelection: (modelSelection) => { | |
| 1515 | − const normalized = normalizeModelSelection(modelSelection); | |
| 1516 | − set((state) => { | |
| 1517 | − if (!normalized) { | |
| 1518 | − return state; | |
| 1813 | + return null; | |
| 1814 | + }, | |
| 1815 | + getDraftThread: (threadRef) => { | |
| 1816 | + if (typeof threadRef === "string") { | |
| 1817 | + return get().getDraftSession(DraftId.makeUnsafe(threadRef)); | |
| 1519 | 1818 | } |
| 1520 | − const nextMap: Partial<Record<ProviderKind, ModelSelection>> = { | |
| 1521 | − ...state.stickyModelSelectionByProvider, | |
| 1522 | − [normalized.provider]: normalized, | |
| 1523 | − }; | |
| 1524 | − if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { | |
| 1525 | − return state.stickyActiveProvider === normalized.provider | |
| 1526 | − ? state | |
| 1527 | − : { stickyActiveProvider: normalized.provider }; | |
| 1819 | + return get().getDraftSessionByRef(threadRef); | |
| 1820 | + }, | |
| 1821 | + getDraftThreadByRef: (threadRef) => { | |
| 1822 | + return get().getDraftSessionByRef(threadRef); | |
| 1823 | + }, | |
| 1824 | + listDraftThreadKeys: () => | |
| 1825 | + Object.values(get().draftThreadsByThreadKey).map((draftThread) => | |
| 1826 | + scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), | |
| 1827 | + ), | |
| 1828 | + hasDraftThreadsInEnvironment: (environmentId) => | |
| 1829 | + Object.values(get().draftThreadsByThreadKey).some( | |
| 1830 | + (draftThread) => draftThread.environmentId === environmentId, | |
| 1831 | + ), | |
| 1832 | + setLogicalProjectDraftThreadId: (logicalProjectKey, projectRef, draftId, options) => { | |
| 1833 | + const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey); | |
| 1834 | + if (normalizedLogicalProjectKey.length === 0 || draftId.length === 0) { | |
| 1835 | + return; | |
| 1528 | 1836 | } |
| 1529 | − return { | |
| 1530 | − stickyModelSelectionByProvider: nextMap, | |
| 1531 | − stickyActiveProvider: normalized.provider, | |
| 1532 | − }; | |
| 1533 | − }); | |
| 1534 | − }, | |
| 1535 | − applyStickyState: (threadId) => { | |
| 1536 | − if (threadId.length === 0) { | |
| 1537 | − return; | |
| 1538 | − } | |
| 1539 | − set((state) => { | |
| 1540 | − const stickyMap = state.stickyModelSelectionByProvider; | |
| 1541 | − const stickyActiveProvider = state.stickyActiveProvider; | |
| 1542 | − if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { | |
| 1543 | − return state; | |
| 1837 | + set((state) => { | |
| 1838 | + const existingThread = state.draftThreadsByThreadKey[draftId]; | |
| 1839 | + const previousThreadKeyForLogicalProject = | |
| 1840 | + state.logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey]; | |
| 1841 | + const nextDraftThread = createDraftThreadState( | |
| 1842 | + projectRef, | |
| 1843 | + options?.threadId ?? existingThread?.threadId ?? ThreadId.makeUnsafe(draftId), | |
| 1844 | + normalizedLogicalProjectKey, | |
| 1845 | + existingThread, | |
| 1846 | + options, | |
| 1847 | + ); | |
| 1848 | + const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId; | |
| 1849 | + if (hasSameLogicalMapping && draftThreadsEqual(existingThread, nextDraftThread)) { | |
| 1850 | + return state; | |
| 1851 | + } | |
| 1852 | + const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> = { | |
| 1853 | + ...state.logicalProjectDraftThreadKeyByLogicalProjectKey, | |
| 1854 | + [normalizedLogicalProjectKey]: draftId, | |
| 1855 | + }; | |
| 1856 | + const nextDraftThreadsByThreadKey: Record<string, DraftThreadState> = { | |
| 1857 | + ...state.draftThreadsByThreadKey, | |
| 1858 | + [draftId]: nextDraftThread, | |
| 1859 | + }; | |
| 1860 | + let nextDraftsByThreadKey = state.draftsByThreadKey; | |
| 1861 | + if ( | |
| 1862 | + previousThreadKeyForLogicalProject && | |
| 1863 | + previousThreadKeyForLogicalProject !== draftId && | |
| 1864 | + !isComposerThreadKeyInUse( | |
| 1865 | + nextLogicalProjectDraftThreadKeyByLogicalProjectKey, | |
| 1866 | + previousThreadKeyForLogicalProject, | |
| 1867 | + ) | |
| 1868 | + ) { | |
| 1869 | + delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; | |
| 1870 | + if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { | |
| 1871 | + nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 1872 | + delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject]; | |
| 1873 | + } | |
| 1874 | + } | |
| 1875 | + return { | |
| 1876 | + draftsByThreadKey: nextDraftsByThreadKey, | |
| 1877 | + draftThreadsByThreadKey: nextDraftThreadsByThreadKey, | |
| 1878 | + logicalProjectDraftThreadKeyByLogicalProjectKey: | |
| 1879 | + nextLogicalProjectDraftThreadKeyByLogicalProjectKey, | |
| 1880 | + }; | |
| 1881 | + }); | |
| 1882 | + }, | |
| 1883 | + setProjectDraftThreadId: (projectRef, draftId, options) => { | |
| 1884 | + get().setLogicalProjectDraftThreadId( | |
| 1885 | + projectDraftKey(projectRef), | |
| 1886 | + projectRef, | |
| 1887 | + draftId, | |
| 1888 | + options, | |
| 1889 | + ); | |
| 1890 | + }, | |
| 1891 | + setDraftThreadContext: (threadRef, options) => { | |
| 1892 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 1893 | + if (threadKey.length === 0) { | |
| 1894 | + return; | |
| 1544 | 1895 | } |
| 1545 | − const existing = state.draftsByThreadId[threadId]; | |
| 1546 | − const base = existing ?? createEmptyThreadDraft(); | |
| 1547 | − const nextMap = { ...base.modelSelectionByProvider }; | |
| 1548 | − for (const [provider, selection] of Object.entries(stickyMap)) { | |
| 1549 | − if (selection) { | |
| 1550 | − const current = nextMap[provider as ProviderKind]; | |
| 1551 | − nextMap[provider as ProviderKind] = { | |
| 1552 | − ...selection, | |
| 1553 | − model: current?.model ?? selection.model, | |
| 1554 | − }; | |
| 1896 | + set((state) => { | |
| 1897 | + const existing = state.draftThreadsByThreadKey[threadKey]; | |
| 1898 | + if (!existing) { | |
| 1899 | + return state; | |
| 1555 | 1900 | } |
| 1901 | + const nextProjectRef = options.projectRef ?? { | |
| 1902 | + environmentId: existing.environmentId, | |
| 1903 | + projectId: existing.projectId, | |
| 1904 | + }; | |
| 1905 | + if ( | |
| 1906 | + nextProjectRef.projectId.length === 0 || | |
| 1907 | + nextProjectRef.environmentId.length === 0 | |
| 1908 | + ) { | |
| 1909 | + return state; | |
| 1910 | + } | |
| 1911 | + const nextWorktreePath = | |
| 1912 | + options.worktreePath === undefined | |
| 1913 | + ? existing.worktreePath | |
| 1914 | + : (options.worktreePath ?? null); | |
| 1915 | + const nextDraftThread: DraftThreadState = { | |
| 1916 | + threadId: existing.threadId, | |
| 1917 | + environmentId: nextProjectRef.environmentId, | |
| 1918 | + projectId: nextProjectRef.projectId, | |
| 1919 | + logicalProjectKey: existing.logicalProjectKey, | |
| 1920 | + createdAt: | |
| 1921 | + options.createdAt === undefined | |
| 1922 | + ? existing.createdAt | |
| 1923 | + : options.createdAt || existing.createdAt, | |
| 1924 | + runtimeMode: options.runtimeMode ?? existing.runtimeMode, | |
| 1925 | + interactionMode: options.interactionMode ?? existing.interactionMode, | |
| 1926 | + branch: options.branch === undefined ? existing.branch : (options.branch ?? null), | |
| 1927 | + worktreePath: nextWorktreePath, | |
| 1928 | + envMode: | |
| 1929 | + options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), | |
| 1930 | + promotedTo: existing.promotedTo ?? null, | |
| 1931 | + }; | |
| 1932 | + const isUnchanged = | |
| 1933 | + nextDraftThread.environmentId === existing.environmentId && | |
| 1934 | + nextDraftThread.projectId === existing.projectId && | |
| 1935 | + nextDraftThread.logicalProjectKey === existing.logicalProjectKey && | |
| 1936 | + nextDraftThread.createdAt === existing.createdAt && | |
| 1937 | + nextDraftThread.runtimeMode === existing.runtimeMode && | |
| 1938 | + nextDraftThread.interactionMode === existing.interactionMode && | |
| 1939 | + nextDraftThread.branch === existing.branch && | |
| 1940 | + nextDraftThread.worktreePath === existing.worktreePath && | |
| 1941 | + nextDraftThread.envMode === existing.envMode && | |
| 1942 | + scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); | |
| 1943 | + if (isUnchanged) { | |
| 1944 | + return state; | |
| 1945 | + } | |
| 1946 | + return { | |
| 1947 | + draftThreadsByThreadKey: { | |
| 1948 | + ...state.draftThreadsByThreadKey, | |
| 1949 | + [threadKey]: nextDraftThread, | |
| 1950 | + }, | |
| 1951 | + }; | |
| 1952 | + }); | |
| 1953 | + }, | |
| 1954 | + clearProjectDraftThreadId: (projectRef) => { | |
| 1955 | + set((state) => { | |
| 1956 | + const matchingThreadEntry = Object.entries(state.draftThreadsByThreadKey).find( | |
| 1957 | + ([, draftThread]) => | |
| 1958 | + draftThread.projectId === projectRef.projectId && | |
| 1959 | + draftThread.environmentId === projectRef.environmentId, | |
| 1960 | + ); | |
| 1961 | + if (!matchingThreadEntry) { | |
| 1962 | + return state; | |
| 1963 | + } | |
| 1964 | + return removeDraftThreadReferences(state, matchingThreadEntry[0]); | |
| 1965 | + }); | |
| 1966 | + }, | |
| 1967 | + clearProjectDraftThreadById: (projectRef, threadRef) => { | |
| 1968 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 1969 | + if (threadKey.length === 0) { | |
| 1970 | + return; | |
| 1556 | 1971 | } |
| 1557 | − if ( | |
| 1558 | − Equal.equals(base.modelSelectionByProvider, nextMap) && | |
| 1559 | − base.activeProvider === stickyActiveProvider | |
| 1560 | − ) { | |
| 1561 | − return state; | |
| 1972 | + set((state) => { | |
| 1973 | + const draftThread = state.draftThreadsByThreadKey[threadKey]; | |
| 1974 | + if ( | |
| 1975 | + !draftThread || | |
| 1976 | + draftThread.projectId !== projectRef.projectId || | |
| 1977 | + draftThread.environmentId !== projectRef.environmentId | |
| 1978 | + ) { | |
| 1979 | + return state; | |
| 1980 | + } | |
| 1981 | + return removeDraftThreadReferences(state, threadKey); | |
| 1982 | + }); | |
| 1983 | + }, | |
| 1984 | + markDraftThreadPromoting: (threadRef, promotedTo) => { | |
| 1985 | + const threadKey = resolveComposerDraftKey(get(), threadRef); | |
| 1986 | + if (!threadKey) { | |
| 1987 | + return; | |
| 1562 | 1988 | } |
| 1563 | − const nextDraft: ComposerThreadDraftState = { | |
| 1564 | − ...base, | |
| 1565 | − modelSelectionByProvider: nextMap, | |
| 1566 | − activeProvider: stickyActiveProvider, | |
| 1567 | − }; | |
| 1568 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1569 | − if (shouldRemoveDraft(nextDraft)) { | |
| 1570 | − delete nextDraftsByThreadId[threadId]; | |
| 1571 | − } else { | |
| 1572 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 1989 | + set((state) => { | |
| 1990 | + const existing = state.draftThreadsByThreadKey[threadKey]; | |
| 1991 | + if (!existing) { | |
| 1992 | + return state; | |
| 1993 | + } | |
| 1994 | + const nextPromotedTo = | |
| 1995 | + promotedTo ?? scopeThreadRef(existing.environmentId, existing.threadId); | |
| 1996 | + if (scopedThreadRefsEqual(existing.promotedTo, nextPromotedTo)) { | |
| 1997 | + return state; | |
| 1998 | + } | |
| 1999 | + return { | |
| 2000 | + draftThreadsByThreadKey: { | |
| 2001 | + ...state.draftThreadsByThreadKey, | |
| 2002 | + [threadKey]: { | |
| 2003 | + ...existing, | |
| 2004 | + promotedTo: nextPromotedTo, | |
| 2005 | + }, | |
| 2006 | + }, | |
| 2007 | + }; | |
| 2008 | + }); | |
| 2009 | + }, | |
| 2010 | + finalizePromotedDraftThread: (threadRef) => { | |
| 2011 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2012 | + if (threadKey.length === 0) { | |
| 2013 | + return; | |
| 1573 | 2014 | } |
| 1574 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 1575 | − }); | |
| 1576 | − }, | |
| 1577 | − setPrompt: (threadId, prompt) => { | |
| 1578 | − if (threadId.length === 0) { | |
| 1579 | − return; | |
| 1580 | − } | |
| 1581 | − set((state) => { | |
| 1582 | − const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); | |
| 1583 | − const nextDraft: ComposerThreadDraftState = { | |
| 1584 | − ...existing, | |
| 1585 | − prompt, | |
| 1586 | − }; | |
| 1587 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1588 | − if (shouldRemoveDraft(nextDraft)) { | |
| 1589 | − delete nextDraftsByThreadId[threadId]; | |
| 1590 | − } else { | |
| 1591 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 2015 | + set((state) => { | |
| 2016 | + const existing = state.draftThreadsByThreadKey[threadKey]; | |
| 2017 | + if (!isDraftThreadPromoting(existing)) { | |
| 2018 | + return state; | |
| 2019 | + } | |
| 2020 | + return removeDraftThreadReferences(state, threadKey); | |
| 2021 | + }); | |
| 2022 | + }, | |
| 2023 | + clearDraftThread: (threadRef) => { | |
| 2024 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2025 | + if (threadKey.length === 0) { | |
| 2026 | + return; | |
| 1592 | 2027 | } |
| 1593 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 1594 | − }); | |
| 1595 | − }, | |
| 1596 | − setTerminalContexts: (threadId, contexts) => { | |
| 1597 | − if (threadId.length === 0) { | |
| 1598 | − return; | |
| 1599 | − } | |
| 1600 | − const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts); | |
| 1601 | − set((state) => { | |
| 1602 | − const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); | |
| 1603 | − const nextDraft: ComposerThreadDraftState = { | |
| 1604 | − ...existing, | |
| 1605 | − prompt: ensureInlineTerminalContextPlaceholders( | |
| 1606 | − existing.prompt, | |
| 1607 | − normalizedContexts.length, | |
| 1608 | − ), | |
| 1609 | − terminalContexts: normalizedContexts, | |
| 1610 | − }; | |
| 1611 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1612 | − if (shouldRemoveDraft(nextDraft)) { | |
| 1613 | − delete nextDraftsByThreadId[threadId]; | |
| 1614 | − } else { | |
| 1615 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 2028 | + const existing = get().draftsByThreadKey[threadKey]; | |
| 2029 | + if (existing) { | |
| 2030 | + for (const image of existing.images) { | |
| 2031 | + revokeObjectPreviewUrl(image.previewUrl); | |
| 2032 | + } | |
| 1616 | 2033 | } |
| 1617 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 1618 | − }); | |
| 1619 | − }, | |
| 1620 | − setModelSelection: (threadId, modelSelection) => { | |
| 1621 | − if (threadId.length === 0) { | |
| 1622 | − return; | |
| 1623 | − } | |
| 1624 | − const normalized = normalizeModelSelection(modelSelection); | |
| 1625 | − set((state) => { | |
| 1626 | − const existing = state.draftsByThreadId[threadId]; | |
| 1627 | − if (!existing && normalized === null) { | |
| 1628 | − return state; | |
| 2034 | + set((state) => { | |
| 2035 | + const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined; | |
| 2036 | + const hasLogicalProjectMapping = Object.values( | |
| 2037 | + state.logicalProjectDraftThreadKeyByLogicalProjectKey, | |
| 2038 | + ).includes(threadKey); | |
| 2039 | + const hasComposerDraft = state.draftsByThreadKey[threadKey] !== undefined; | |
| 2040 | + if (!hasDraftThread && !hasLogicalProjectMapping && !hasComposerDraft) { | |
| 2041 | + return state; | |
| 2042 | + } | |
| 2043 | + return removeDraftThreadReferences(state, threadKey); | |
| 2044 | + }); | |
| 2045 | + }, | |
| 2046 | + setStickyModelSelection: (modelSelection) => { | |
| 2047 | + const normalized = normalizeModelSelection(modelSelection); | |
| 2048 | + set((state) => { | |
| 2049 | + if (!normalized) { | |
| 2050 | + return state; | |
| 2051 | + } | |
| 2052 | + const nextMap: Partial<Record<ProviderKind, ModelSelection>> = { | |
| 2053 | + ...state.stickyModelSelectionByProvider, | |
| 2054 | + [normalized.provider]: normalized, | |
| 2055 | + }; | |
| 2056 | + if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { | |
| 2057 | + return state.stickyActiveProvider === normalized.provider | |
| 2058 | + ? state | |
| 2059 | + : { stickyActiveProvider: normalized.provider }; | |
| 2060 | + } | |
| 2061 | + return { | |
| 2062 | + stickyModelSelectionByProvider: nextMap, | |
| 2063 | + stickyActiveProvider: normalized.provider, | |
| 2064 | + }; | |
| 2065 | + }); | |
| 2066 | + }, | |
| 2067 | + applyStickyState: (threadRef) => { | |
| 2068 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2069 | + if (threadKey.length === 0) { | |
| 2070 | + return; | |
| 1629 | 2071 | } |
| 1630 | − const base = existing ?? createEmptyThreadDraft(); | |
| 1631 | − const nextMap = { ...base.modelSelectionByProvider }; | |
| 1632 | − if (normalized) { | |
| 1633 | − const current = nextMap[normalized.provider]; | |
| 1634 | − if (normalized.options !== undefined) { | |
| 1635 | − // Explicit options provided → use them | |
| 1636 | − nextMap[normalized.provider] = normalized; | |
| 2072 | + set((state) => { | |
| 2073 | + const stickyMap = state.stickyModelSelectionByProvider; | |
| 2074 | + const stickyActiveProvider = state.stickyActiveProvider; | |
| 2075 | + if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { | |
| 2076 | + return state; | |
| 2077 | + } | |
| 2078 | + const existing = state.draftsByThreadKey[threadKey]; | |
| 2079 | + const base = existing ?? createEmptyThreadDraft(); | |
| 2080 | + const nextMap = { ...base.modelSelectionByProvider }; | |
| 2081 | + for (const [provider, selection] of Object.entries(stickyMap)) { | |
| 2082 | + if (selection) { | |
| 2083 | + const current = nextMap[provider as ProviderKind]; | |
| 2084 | + nextMap[provider as ProviderKind] = { | |
| 2085 | + ...selection, | |
| 2086 | + model: current?.model ?? selection.model, | |
| 2087 | + }; | |
| 2088 | + } | |
| 2089 | + } | |
| 2090 | + if ( | |
| 2091 | + Equal.equals(base.modelSelectionByProvider, nextMap) && | |
| 2092 | + base.activeProvider === stickyActiveProvider | |
| 2093 | + ) { | |
| 2094 | + return state; | |
| 2095 | + } | |
| 2096 | + const nextDraft: ComposerThreadDraftState = { | |
| 2097 | + ...base, | |
| 2098 | + modelSelectionByProvider: nextMap, | |
| 2099 | + activeProvider: stickyActiveProvider, | |
| 2100 | + }; | |
| 2101 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2102 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2103 | + delete nextDraftsByThreadKey[threadKey]; | |
| 1637 | 2104 | } else { |
| 1638 | − // No options in selection → preserve existing options, update provider+model | |
| 1639 | − nextMap[normalized.provider] = { | |
| 1640 | − provider: normalized.provider, | |
| 1641 | − model: normalized.model, | |
| 1642 | − ...(current?.options ? { options: current.options } : {}), | |
| 1643 | − }; | |
| 2105 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 1644 | 2106 | } |
| 2107 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2108 | + }); | |
| 2109 | + }, | |
| 2110 | + setPrompt: (threadRef, prompt) => { | |
| 2111 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2112 | + if (threadKey.length === 0) { | |
| 2113 | + return; | |
| 1645 | 2114 | } |
| 1646 | − const nextActiveProvider = normalized?.provider ?? base.activeProvider; | |
| 1647 | − if ( | |
| 1648 | − Equal.equals(base.modelSelectionByProvider, nextMap) && | |
| 1649 | − base.activeProvider === nextActiveProvider | |
| 1650 | − ) { | |
| 1651 | − return state; | |
| 1652 | − } | |
| 1653 | − const nextDraft: ComposerThreadDraftState = { | |
| 1654 | − ...base, | |
| 1655 | − modelSelectionByProvider: nextMap, | |
| 1656 | − activeProvider: nextActiveProvider, | |
| 1657 | − }; | |
| 1658 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1659 | − if (shouldRemoveDraft(nextDraft)) { | |
| 1660 | − delete nextDraftsByThreadId[threadId]; | |
| 1661 | − } else { | |
| 1662 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 1663 | − } | |
| 1664 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 1665 | − }); | |
| 1666 | − }, | |
| 1667 | − setModelOptions: (threadId, modelOptions) => { | |
| 1668 | − if (threadId.length === 0) { | |
| 1669 | − return; | |
| 1670 | − } | |
| 1671 | − const normalizedOpts = normalizeProviderModelOptions(modelOptions); | |
| 1672 | − set((state) => { | |
| 1673 | − const existing = state.draftsByThreadId[threadId]; | |
| 1674 | − if (!existing && normalizedOpts === null) { | |
| 1675 | − return state; | |
| 1676 | − } | |
| 1677 | − const base = existing ?? createEmptyThreadDraft(); | |
| 1678 | − const nextMap = { ...base.modelSelectionByProvider }; | |
| 1679 | − for (const provider of ["codex", "claudeAgent"] as const) { | |
| 1680 | − // Only touch providers explicitly present in the input | |
| 1681 | − if (!normalizedOpts || !(provider in normalizedOpts)) continue; | |
| 1682 | − const opts = normalizedOpts[provider]; | |
| 1683 | − const current = nextMap[provider]; | |
| 1684 | − if (opts) { | |
| 1685 | − nextMap[provider] = { | |
| 1686 | − provider, | |
| 1687 | − model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], | |
| 1688 | − options: opts, | |
| 1689 | − }; | |
| 1690 | − } else if (current?.options) { | |
| 1691 | − // Remove options but keep the selection | |
| 1692 | − const { options: _, ...rest } = current; | |
| 1693 | − nextMap[provider] = rest as ModelSelection; | |
| 2115 | + set((state) => { | |
| 2116 | + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); | |
| 2117 | + const nextDraft: ComposerThreadDraftState = { | |
| 2118 | + ...existing, | |
| 2119 | + prompt, | |
| 2120 | + }; | |
| 2121 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2122 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2123 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2124 | + } else { | |
| 2125 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 1694 | 2126 | } |
| 2127 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2128 | + }); | |
| 2129 | + }, | |
| 2130 | + setTerminalContexts: (threadRef, contexts) => { | |
| 2131 | + const threadKey = resolveComposerDraftKey(get(), threadRef); | |
| 2132 | + const threadId = resolveComposerThreadId(get(), threadRef); | |
| 2133 | + if (!threadKey || !threadId) { | |
| 2134 | + return; | |
| 1695 | 2135 | } |
| 1696 | − if (Equal.equals(base.modelSelectionByProvider, nextMap)) { | |
| 1697 | − return state; | |
| 2136 | + const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts); | |
| 2137 | + set((state) => { | |
| 2138 | + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); | |
| 2139 | + const nextDraft: ComposerThreadDraftState = { | |
| 2140 | + ...existing, | |
| 2141 | + prompt: ensureInlineTerminalContextPlaceholders( | |
| 2142 | + existing.prompt, | |
| 2143 | + normalizedContexts.length, | |
| 2144 | + ), | |
| 2145 | + terminalContexts: normalizedContexts, | |
| 2146 | + }; | |
| 2147 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2148 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2149 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2150 | + } else { | |
| 2151 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2152 | + } | |
| 2153 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2154 | + }); | |
| 2155 | + }, | |
| 2156 | + setModelSelection: (threadRef, modelSelection) => { | |
| 2157 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2158 | + if (threadKey.length === 0) { | |
| 2159 | + return; | |
| 1698 | 2160 | } |
| 1699 | − const nextDraft: ComposerThreadDraftState = { | |
| 1700 | − ...base, | |
| 1701 | − modelSelectionByProvider: nextMap, | |
| 1702 | − }; | |
| 1703 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1704 | − if (shouldRemoveDraft(nextDraft)) { | |
| 1705 | − delete nextDraftsByThreadId[threadId]; | |
| 1706 | − } else { | |
| 1707 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 2161 | + const normalized = normalizeModelSelection(modelSelection); | |
| 2162 | + set((state) => { | |
| 2163 | + const existing = state.draftsByThreadKey[threadKey]; | |
| 2164 | + if (!existing && normalized === null) { | |
| 2165 | + return state; | |
| 2166 | + } | |
| 2167 | + const base = existing ?? createEmptyThreadDraft(); | |
| 2168 | + const nextMap = { ...base.modelSelectionByProvider }; | |
| 2169 | + if (normalized) { | |
| 2170 | + const current = nextMap[normalized.provider]; | |
| 2171 | + if (normalized.options !== undefined) { | |
| 2172 | + // Explicit options provided → use them | |
| 2173 | + nextMap[normalized.provider] = normalized; | |
| 2174 | + } else { | |
| 2175 | + // No options in selection → preserve existing options, update provider+model | |
| 2176 | + nextMap[normalized.provider] = { | |
| 2177 | + provider: normalized.provider, | |
| 2178 | + model: normalized.model, | |
| 2179 | + ...(current?.options ? { options: current.options } : {}), | |
| 2180 | + }; | |
| 2181 | + } | |
| 2182 | + } | |
| 2183 | + const nextActiveProvider = normalized?.provider ?? base.activeProvider; | |
| 2184 | + if ( | |
| 2185 | + Equal.equals(base.modelSelectionByProvider, nextMap) && | |
| 2186 | + base.activeProvider === nextActiveProvider | |
| 2187 | + ) { | |
| 2188 | + return state; | |
| 2189 | + } | |
| 2190 | + const nextDraft: ComposerThreadDraftState = { | |
| 2191 | + ...base, | |
| 2192 | + modelSelectionByProvider: nextMap, | |
| 2193 | + activeProvider: nextActiveProvider, | |
| 2194 | + }; | |
| 2195 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2196 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2197 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2198 | + } else { | |
| 2199 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2200 | + } | |
| 2201 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2202 | + }); | |
| 2203 | + }, | |
| 2204 | + setModelOptions: (threadRef, modelOptions) => { | |
| 2205 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2206 | + if (threadKey.length === 0) { | |
| 2207 | + return; | |
| 1708 | 2208 | } |
| 1709 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 1710 | − }); | |
| 1711 | − }, | |
| 1712 | − setProviderModelOptions: (threadId, provider, nextProviderOptions, options) => { | |
| 1713 | − if (threadId.length === 0) { | |
| 1714 | − return; | |
| 1715 | − } | |
| 1716 | − const normalizedProvider = normalizeProviderKind(provider); | |
| 1717 | − if (normalizedProvider === null) { | |
| 1718 | − return; | |
| 1719 | − } | |
| 1720 | − // Normalize just this provider's options | |
| 1721 | − const normalizedOpts = normalizeProviderModelOptions( | |
| 1722 | − { [normalizedProvider]: nextProviderOptions }, | |
| 1723 | − normalizedProvider, | |
| 1724 | − ); | |
| 1725 | − const providerOpts = normalizedOpts?.[normalizedProvider]; | |
| 1726 | − | |
| 1727 | − set((state) => { | |
| 1728 | − const existing = state.draftsByThreadId[threadId]; | |
| 1729 | − const base = existing ?? createEmptyThreadDraft(); | |
| 1730 | − | |
| 1731 | − // Update the map entry for this provider | |
| 1732 | − const nextMap = { ...base.modelSelectionByProvider }; | |
| 1733 | − const currentForProvider = nextMap[normalizedProvider]; | |
| 1734 | − if (providerOpts) { | |
| 1735 | − nextMap[normalizedProvider] = { | |
| 1736 | − provider: normalizedProvider, | |
| 1737 | − model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | |
| 1738 | − options: providerOpts, | |
| 2209 | + const normalizedOpts = normalizeProviderModelOptions(modelOptions); | |
| 2210 | + set((state) => { | |
| 2211 | + const existing = state.draftsByThreadKey[threadKey]; | |
| 2212 | + if (!existing && normalizedOpts === null) { | |
| 2213 | + return state; | |
| 2214 | + } | |
| 2215 | + const base = existing ?? createEmptyThreadDraft(); | |
| 2216 | + const nextMap = { ...base.modelSelectionByProvider }; | |
| 2217 | + for (const provider of ["codex", "claudeAgent"] as const) { | |
| 2218 | + // Only touch providers explicitly present in the input | |
| 2219 | + if (!normalizedOpts || !(provider in normalizedOpts)) continue; | |
| 2220 | + const opts = normalizedOpts[provider]; | |
| 2221 | + const current = nextMap[provider]; | |
| 2222 | + if (opts) { | |
| 2223 | + nextMap[provider] = { | |
| 2224 | + provider, | |
| 2225 | + model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], | |
| 2226 | + options: opts, | |
| 2227 | + }; | |
| 2228 | + } else if (current?.options) { | |
| 2229 | + // Remove options but keep the selection | |
| 2230 | + const { options: _, ...rest } = current; | |
| 2231 | + nextMap[provider] = rest as ModelSelection; | |
| 2232 | + } | |
| 2233 | + } | |
| 2234 | + if (Equal.equals(base.modelSelectionByProvider, nextMap)) { | |
| 2235 | + return state; | |
| 2236 | + } | |
| 2237 | + const nextDraft: ComposerThreadDraftState = { | |
| 2238 | + ...base, | |
| 2239 | + modelSelectionByProvider: nextMap, | |
| 1739 | 2240 | }; |
| 1740 | − } else if (currentForProvider?.options) { | |
| 1741 | − const { options: _, ...rest } = currentForProvider; | |
| 1742 | − nextMap[normalizedProvider] = rest as ModelSelection; | |
| 2241 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2242 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2243 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2244 | + } else { | |
| 2245 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2246 | + } | |
| 2247 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2248 | + }); | |
| 2249 | + }, | |
| 2250 | + setProviderModelOptions: (threadRef, provider, nextProviderOptions, options) => { | |
| 2251 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2252 | + if (threadKey.length === 0) { | |
| 2253 | + return; | |
| 1743 | 2254 | } |
| 2255 | + const normalizedProvider = normalizeProviderKind(provider); | |
| 2256 | + if (normalizedProvider === null) { | |
| 2257 | + return; | |
| 2258 | + } | |
| 2259 | + // Normalize just this provider's options | |
| 2260 | + const normalizedOpts = normalizeProviderModelOptions( | |
| 2261 | + { [normalizedProvider]: nextProviderOptions }, | |
| 2262 | + normalizedProvider, | |
| 2263 | + ); | |
| 2264 | + const providerOpts = normalizedOpts?.[normalizedProvider]; | |
| 1744 | 2265 | |
| 1745 | − // Handle sticky persistence | |
| 1746 | − let nextStickyMap = state.stickyModelSelectionByProvider; | |
| 1747 | − let nextStickyActiveProvider = state.stickyActiveProvider; | |
| 1748 | − if (options?.persistSticky === true) { | |
| 1749 | − nextStickyMap = { ...state.stickyModelSelectionByProvider }; | |
| 1750 | − const stickyBase = | |
| 1751 | − nextStickyMap[normalizedProvider] ?? | |
| 1752 | − base.modelSelectionByProvider[normalizedProvider] ?? | |
| 1753 | − ({ | |
| 1754 | − provider: normalizedProvider, | |
| 1755 | − model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | |
| 1756 | − } as ModelSelection); | |
| 2266 | + set((state) => { | |
| 2267 | + const existing = state.draftsByThreadKey[threadKey]; | |
| 2268 | + const base = existing ?? createEmptyThreadDraft(); | |
| 2269 | + | |
| 2270 | + // Update the map entry for this provider | |
| 2271 | + const nextMap = { ...base.modelSelectionByProvider }; | |
| 2272 | + const currentForProvider = nextMap[normalizedProvider]; | |
| 1757 | 2273 | if (providerOpts) { |
| 1758 | − nextStickyMap[normalizedProvider] = { | |
| 1759 | − ...stickyBase, | |
| 2274 | + nextMap[normalizedProvider] = { | |
| 1760 | 2275 | provider: normalizedProvider, |
| 2276 | + model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | |
| 1761 | 2277 | options: providerOpts, |
| 1762 | 2278 | }; |
| 1763 | − } else if (stickyBase.options) { | |
| 1764 | − const { options: _, ...rest } = stickyBase; | |
| 1765 | − nextStickyMap[normalizedProvider] = rest as ModelSelection; | |
| 2279 | + } else if (currentForProvider?.options) { | |
| 2280 | + const { options: _, ...rest } = currentForProvider; | |
| 2281 | + nextMap[normalizedProvider] = rest as ModelSelection; | |
| 1766 | 2282 | } |
| 1767 | − nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; | |
| 1768 | − } | |
| 1769 | 2283 | |
| 1770 | − if ( | |
| 1771 | − Equal.equals(base.modelSelectionByProvider, nextMap) && | |
| 1772 | − Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && | |
| 1773 | − state.stickyActiveProvider === nextStickyActiveProvider | |
| 1774 | − ) { | |
| 1775 | − return state; | |
| 1776 | − } | |
| 2284 | + // Handle sticky persistence | |
| 2285 | + let nextStickyMap = state.stickyModelSelectionByProvider; | |
| 2286 | + let nextStickyActiveProvider = state.stickyActiveProvider; | |
| 2287 | + if (options?.persistSticky === true) { | |
| 2288 | + nextStickyMap = { ...state.stickyModelSelectionByProvider }; | |
| 2289 | + const stickyBase = | |
| 2290 | + nextStickyMap[normalizedProvider] ?? | |
| 2291 | + base.modelSelectionByProvider[normalizedProvider] ?? | |
| 2292 | + ({ | |
| 2293 | + provider: normalizedProvider, | |
| 2294 | + model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | |
| 2295 | + } as ModelSelection); | |
| 2296 | + if (providerOpts) { | |
| 2297 | + nextStickyMap[normalizedProvider] = { | |
| 2298 | + ...stickyBase, | |
| 2299 | + provider: normalizedProvider, | |
| 2300 | + options: providerOpts, | |
| 2301 | + }; | |
| 2302 | + } else if (stickyBase.options) { | |
| 2303 | + const { options: _, ...rest } = stickyBase; | |
| 2304 | + nextStickyMap[normalizedProvider] = rest as ModelSelection; | |
| 2305 | + } | |
| 2306 | + nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; | |
| 2307 | + } | |
| 1777 | 2308 | |
| 1778 | − const nextDraft: ComposerThreadDraftState = { | |
| 1779 | − ...base, | |
| 1780 | − modelSelectionByProvider: nextMap, | |
| 1781 | − }; | |
| 1782 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1783 | − if (shouldRemoveDraft(nextDraft)) { | |
| 1784 | − delete nextDraftsByThreadId[threadId]; | |
| 1785 | − } else { | |
| 1786 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 1787 | − } | |
| 2309 | + if ( | |
| 2310 | + Equal.equals(base.modelSelectionByProvider, nextMap) && | |
| 2311 | + Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && | |
| 2312 | + state.stickyActiveProvider === nextStickyActiveProvider | |
| 2313 | + ) { | |
| 2314 | + return state; | |
| 2315 | + } | |
| 1788 | 2316 | |
| 1789 | − return { | |
| 1790 | − draftsByThreadId: nextDraftsByThreadId, | |
| 1791 | − ...(options?.persistSticky === true | |
| 1792 | − ? { | |
| 1793 | − stickyModelSelectionByProvider: nextStickyMap, | |
| 1794 | − stickyActiveProvider: nextStickyActiveProvider, | |
| 1795 | − } | |
| 1796 | − : {}), | |
| 1797 | − }; | |
| 1798 | − }); | |
| 1799 | − }, | |
| 1800 | − setRuntimeMode: (threadId, runtimeMode) => { | |
| 1801 | − if (threadId.length === 0) { | |
| 1802 | − return; | |
| 1803 | − } | |
| 1804 | − const nextRuntimeMode = | |
| 1805 | − runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null; | |
| 1806 | − set((state) => { | |
| 1807 | − const existing = state.draftsByThreadId[threadId]; | |
| 1808 | − if (!existing && nextRuntimeMode === null) { | |
| 1809 | − return state; | |
| 1810 | − } | |
| 1811 | − const base = existing ?? createEmptyThreadDraft(); | |
| 1812 | − if (base.runtimeMode === nextRuntimeMode) { | |
| 1813 | − return state; | |
| 1814 | − } | |
| 1815 | − const nextDraft: ComposerThreadDraftState = { | |
| 1816 | − ...base, | |
| 1817 | − runtimeMode: nextRuntimeMode, | |
| 1818 | − }; | |
| 1819 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1820 | − if (shouldRemoveDraft(nextDraft)) { | |
| 1821 | − delete nextDraftsByThreadId[threadId]; | |
| 1822 | − } else { | |
| 1823 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 1824 | − } | |
| 1825 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 1826 | − }); | |
| 1827 | − }, | |
| 1828 | − setInteractionMode: (threadId, interactionMode) => { | |
| 1829 | − if (threadId.length === 0) { | |
| 1830 | − return; | |
| 1831 | − } | |
| 1832 | − const nextInteractionMode = | |
| 1833 | − interactionMode === "plan" || interactionMode === "default" ? interactionMode : null; | |
| 1834 | − set((state) => { | |
| 1835 | − const existing = state.draftsByThreadId[threadId]; | |
| 1836 | − if (!existing && nextInteractionMode === null) { | |
| 1837 | − return state; | |
| 2317 | + const nextDraft: ComposerThreadDraftState = { | |
| 2318 | + ...base, | |
| 2319 | + modelSelectionByProvider: nextMap, | |
| 2320 | + }; | |
| 2321 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2322 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2323 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2324 | + } else { | |
| 2325 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2326 | + } | |
| 2327 | + | |
| 2328 | + return { | |
| 2329 | + draftsByThreadKey: nextDraftsByThreadKey, | |
| 2330 | + ...(options?.persistSticky === true | |
| 2331 | + ? { | |
| 2332 | + stickyModelSelectionByProvider: nextStickyMap, | |
| 2333 | + stickyActiveProvider: nextStickyActiveProvider, | |
| 2334 | + } | |
| 2335 | + : {}), | |
| 2336 | + }; | |
| 2337 | + }); | |
| 2338 | + }, | |
| 2339 | + setRuntimeMode: (threadRef, runtimeMode) => { | |
| 2340 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2341 | + if (threadKey.length === 0) { | |
| 2342 | + return; | |
| 1838 | 2343 | } |
| 1839 | − const base = existing ?? createEmptyThreadDraft(); | |
| 1840 | − if (base.interactionMode === nextInteractionMode) { | |
| 1841 | − return state; | |
| 2344 | + const nextRuntimeMode = | |
| 2345 | + runtimeMode === "approval-required" || runtimeMode === "full-access" | |
| 2346 | + ? runtimeMode | |
| 2347 | + : null; | |
| 2348 | + set((state) => { | |
| 2349 | + const existing = state.draftsByThreadKey[threadKey]; | |
| 2350 | + if (!existing && nextRuntimeMode === null) { | |
| 2351 | + return state; | |
| 2352 | + } | |
| 2353 | + const base = existing ?? createEmptyThreadDraft(); | |
| 2354 | + if (base.runtimeMode === nextRuntimeMode) { | |
| 2355 | + return state; | |
| 2356 | + } | |
| 2357 | + const nextDraft: ComposerThreadDraftState = { | |
| 2358 | + ...base, | |
| 2359 | + runtimeMode: nextRuntimeMode, | |
| 2360 | + }; | |
| 2361 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2362 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2363 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2364 | + } else { | |
| 2365 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2366 | + } | |
| 2367 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2368 | + }); | |
| 2369 | + }, | |
| 2370 | + setInteractionMode: (threadRef, interactionMode) => { | |
| 2371 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2372 | + if (threadKey.length === 0) { | |
| 2373 | + return; | |
| 1842 | 2374 | } |
| 1843 | − const nextDraft: ComposerThreadDraftState = { | |
| 1844 | − ...base, | |
| 1845 | − interactionMode: nextInteractionMode, | |
| 1846 | − }; | |
| 1847 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1848 | − if (shouldRemoveDraft(nextDraft)) { | |
| 1849 | − delete nextDraftsByThreadId[threadId]; | |
| 1850 | − } else { | |
| 1851 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 2375 | + const nextInteractionMode = | |
| 2376 | + interactionMode === "plan" || interactionMode === "default" ? interactionMode : null; | |
| 2377 | + set((state) => { | |
| 2378 | + const existing = state.draftsByThreadKey[threadKey]; | |
| 2379 | + if (!existing && nextInteractionMode === null) { | |
| 2380 | + return state; | |
| 2381 | + } | |
| 2382 | + const base = existing ?? createEmptyThreadDraft(); | |
| 2383 | + if (base.interactionMode === nextInteractionMode) { | |
| 2384 | + return state; | |
| 2385 | + } | |
| 2386 | + const nextDraft: ComposerThreadDraftState = { | |
| 2387 | + ...base, | |
| 2388 | + interactionMode: nextInteractionMode, | |
| 2389 | + }; | |
| 2390 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2391 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2392 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2393 | + } else { | |
| 2394 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2395 | + } | |
| 2396 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2397 | + }); | |
| 2398 | + }, | |
| 2399 | + addImage: (threadRef, image) => { | |
| 2400 | + const threadKey = resolveComposerDraftKey(get(), threadRef); | |
| 2401 | + const threadId = resolveComposerThreadId(get(), threadRef); | |
| 2402 | + if (!threadKey || !threadId) { | |
| 2403 | + return; | |
| 1852 | 2404 | } |
| 1853 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 1854 | − }); | |
| 1855 | − }, | |
| 1856 | − addImage: (threadId, image) => { | |
| 1857 | − if (threadId.length === 0) { | |
| 1858 | − return; | |
| 1859 | − } | |
| 1860 | − get().addImages(threadId, [image]); | |
| 1861 | − }, | |
| 1862 | − addImages: (threadId, images) => { | |
| 1863 | − if (threadId.length === 0 || images.length === 0) { | |
| 1864 | − return; | |
| 1865 | − } | |
| 1866 | − set((state) => { | |
| 1867 | − const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); | |
| 1868 | − const existingIds = new Set(existing.images.map((image) => image.id)); | |
| 1869 | − const existingDedupKeys = new Set( | |
| 1870 | − existing.images.map((image) => composerImageDedupKey(image)), | |
| 2405 | + get().addImages( | |
| 2406 | + typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef, | |
| 2407 | + [image], | |
| 1871 | 2408 | ); |
| 1872 | − const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl)); | |
| 1873 | − const dedupedIncoming: ComposerImageAttachment[] = []; | |
| 1874 | − for (const image of images) { | |
| 1875 | − const dedupKey = composerImageDedupKey(image); | |
| 1876 | − if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) { | |
| 1877 | − // Avoid revoking a blob URL that's still referenced by an accepted image. | |
| 1878 | − if (!acceptedPreviewUrls.has(image.previewUrl)) { | |
| 1879 | − revokeObjectPreviewUrl(image.previewUrl); | |
| 2409 | + }, | |
| 2410 | + addImages: (threadRef, images) => { | |
| 2411 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2412 | + if (threadKey.length === 0 || images.length === 0) { | |
| 2413 | + return; | |
| 2414 | + } | |
| 2415 | + set((state) => { | |
| 2416 | + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); | |
| 2417 | + const existingIds = new Set(existing.images.map((image) => image.id)); | |
| 2418 | + const existingDedupKeys = new Set( | |
| 2419 | + existing.images.map((image) => composerImageDedupKey(image)), | |
| 2420 | + ); | |
| 2421 | + const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl)); | |
| 2422 | + const dedupedIncoming: ComposerImageAttachment[] = []; | |
| 2423 | + for (const image of images) { | |
| 2424 | + const dedupKey = composerImageDedupKey(image); | |
| 2425 | + if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) { | |
| 2426 | + // Avoid revoking a blob URL that's still referenced by an accepted image. | |
| 2427 | + if (!acceptedPreviewUrls.has(image.previewUrl)) { | |
| 2428 | + revokeObjectPreviewUrl(image.previewUrl); | |
| 2429 | + } | |
| 2430 | + continue; | |
| 1880 | 2431 | } |
| 1881 | − continue; | |
| 2432 | + dedupedIncoming.push(image); | |
| 2433 | + existingIds.add(image.id); | |
| 2434 | + existingDedupKeys.add(dedupKey); | |
| 2435 | + acceptedPreviewUrls.add(image.previewUrl); | |
| 1882 | 2436 | } |
| 1883 | − dedupedIncoming.push(image); | |
| 1884 | − existingIds.add(image.id); | |
| 1885 | − existingDedupKeys.add(dedupKey); | |
| 1886 | − acceptedPreviewUrls.add(image.previewUrl); | |
| 1887 | − } | |
| 1888 | − if (dedupedIncoming.length === 0) { | |
| 1889 | − return state; | |
| 1890 | − } | |
| 1891 | − return { | |
| 1892 | − draftsByThreadId: { | |
| 1893 | − ...state.draftsByThreadId, | |
| 1894 | − [threadId]: { | |
| 1895 | − ...existing, | |
| 1896 | − images: [...existing.images, ...dedupedIncoming], | |
| 2437 | + if (dedupedIncoming.length === 0) { | |
| 2438 | + return state; | |
| 2439 | + } | |
| 2440 | + return { | |
| 2441 | + draftsByThreadKey: { | |
| 2442 | + ...state.draftsByThreadKey, | |
| 2443 | + [threadKey]: { | |
| 2444 | + ...existing, | |
| 2445 | + images: [...existing.images, ...dedupedIncoming], | |
| 2446 | + }, | |
| 1897 | 2447 | }, |
| 1898 | − }, | |
| 1899 | − }; | |
| 1900 | − }); | |
| 1901 | − }, | |
| 1902 | − removeImage: (threadId, imageId) => { | |
| 1903 | − if (threadId.length === 0) { | |
| 1904 | − return; | |
| 1905 | − } | |
| 1906 | − const existing = get().draftsByThreadId[threadId]; | |
| 1907 | − if (!existing) { | |
| 1908 | − return; | |
| 1909 | − } | |
| 1910 | − const removedImage = existing.images.find((image) => image.id === imageId); | |
| 1911 | − if (removedImage) { | |
| 1912 | − revokeObjectPreviewUrl(removedImage.previewUrl); | |
| 1913 | − } | |
| 1914 | − set((state) => { | |
| 1915 | − const current = state.draftsByThreadId[threadId]; | |
| 1916 | − if (!current) { | |
| 1917 | − return state; | |
| 1918 | − } | |
| 1919 | − const nextDraft: ComposerThreadDraftState = { | |
| 1920 | − ...current, | |
| 1921 | − images: current.images.filter((image) => image.id !== imageId), | |
| 1922 | − nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId), | |
| 1923 | − persistedAttachments: current.persistedAttachments.filter( | |
| 1924 | − (attachment) => attachment.id !== imageId, | |
| 1925 | − ), | |
| 1926 | − }; | |
| 1927 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1928 | − if (shouldRemoveDraft(nextDraft)) { | |
| 1929 | − delete nextDraftsByThreadId[threadId]; | |
| 1930 | − } else { | |
| 1931 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 2448 | + }; | |
| 2449 | + }); | |
| 2450 | + }, | |
| 2451 | + removeImage: (threadRef, imageId) => { | |
| 2452 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2453 | + if (threadKey.length === 0) { | |
| 2454 | + return; | |
| 1932 | 2455 | } |
| 1933 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 1934 | − }); | |
| 1935 | − }, | |
| 1936 | − insertTerminalContext: (threadId, prompt, context, index) => { | |
| 1937 | − if (threadId.length === 0) { | |
| 1938 | − return false; | |
| 1939 | − } | |
| 1940 | − let inserted = false; | |
| 1941 | − set((state) => { | |
| 1942 | − const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); | |
| 1943 | − const normalizedContext = normalizeTerminalContextForThread(threadId, context); | |
| 1944 | − if (!normalizedContext) { | |
| 1945 | − return state; | |
| 2456 | + const existing = get().draftsByThreadKey[threadKey]; | |
| 2457 | + if (!existing) { | |
| 2458 | + return; | |
| 1946 | 2459 | } |
| 1947 | − const dedupKey = terminalContextDedupKey(normalizedContext); | |
| 1948 | − if ( | |
| 1949 | − existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) || | |
| 1950 | − existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey) | |
| 1951 | − ) { | |
| 1952 | − return state; | |
| 2460 | + const removedImage = existing.images.find((image) => image.id === imageId); | |
| 2461 | + if (removedImage) { | |
| 2462 | + revokeObjectPreviewUrl(removedImage.previewUrl); | |
| 1953 | 2463 | } |
| 1954 | − inserted = true; | |
| 1955 | − const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index)); | |
| 1956 | − const nextDraft: ComposerThreadDraftState = { | |
| 1957 | − ...existing, | |
| 1958 | − prompt, | |
| 1959 | − terminalContexts: [ | |
| 1960 | − ...existing.terminalContexts.slice(0, boundedIndex), | |
| 1961 | − normalizedContext, | |
| 1962 | − ...existing.terminalContexts.slice(boundedIndex), | |
| 1963 | − ], | |
| 1964 | − }; | |
| 1965 | − return { | |
| 1966 | − draftsByThreadId: { | |
| 1967 | − ...state.draftsByThreadId, | |
| 1968 | − [threadId]: nextDraft, | |
| 1969 | − }, | |
| 1970 | − }; | |
| 1971 | − }); | |
| 1972 | − return inserted; | |
| 1973 | − }, | |
| 1974 | − addTerminalContext: (threadId, context) => { | |
| 1975 | − if (threadId.length === 0) { | |
| 1976 | − return; | |
| 1977 | − } | |
| 1978 | − get().addTerminalContexts(threadId, [context]); | |
| 1979 | − }, | |
| 1980 | − addTerminalContexts: (threadId, contexts) => { | |
| 1981 | − if (threadId.length === 0 || contexts.length === 0) { | |
| 1982 | − return; | |
| 1983 | − } | |
| 1984 | − set((state) => { | |
| 1985 | − const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); | |
| 1986 | − const acceptedContexts = normalizeTerminalContextsForThread(threadId, [ | |
| 1987 | − ...existing.terminalContexts, | |
| 1988 | − ...contexts, | |
| 1989 | − ]).slice(existing.terminalContexts.length); | |
| 1990 | − if (acceptedContexts.length === 0) { | |
| 1991 | − return state; | |
| 2464 | + set((state) => { | |
| 2465 | + const current = state.draftsByThreadKey[threadKey]; | |
| 2466 | + if (!current) { | |
| 2467 | + return state; | |
| 2468 | + } | |
| 2469 | + const nextDraft: ComposerThreadDraftState = { | |
| 2470 | + ...current, | |
| 2471 | + images: current.images.filter((image) => image.id !== imageId), | |
| 2472 | + nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId), | |
| 2473 | + persistedAttachments: current.persistedAttachments.filter( | |
| 2474 | + (attachment) => attachment.id !== imageId, | |
| 2475 | + ), | |
| 2476 | + }; | |
| 2477 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2478 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2479 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2480 | + } else { | |
| 2481 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2482 | + } | |
| 2483 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2484 | + }); | |
| 2485 | + }, | |
| 2486 | + insertTerminalContext: (threadRef, prompt, context, index) => { | |
| 2487 | + const threadKey = resolveComposerDraftKey(get(), threadRef); | |
| 2488 | + const threadId = resolveComposerThreadId(get(), threadRef); | |
| 2489 | + if (!threadKey || !threadId) { | |
| 2490 | + return false; | |
| 1992 | 2491 | } |
| 1993 | − return { | |
| 1994 | − draftsByThreadId: { | |
| 1995 | − ...state.draftsByThreadId, | |
| 1996 | − [threadId]: { | |
| 1997 | − ...existing, | |
| 1998 | − prompt: ensureInlineTerminalContextPlaceholders( | |
| 1999 | − existing.prompt, | |
| 2000 | − existing.terminalContexts.length + acceptedContexts.length, | |
| 2001 | − ), | |
| 2002 | − terminalContexts: [...existing.terminalContexts, ...acceptedContexts], | |
| 2492 | + let inserted = false; | |
| 2493 | + set((state) => { | |
| 2494 | + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); | |
| 2495 | + const normalizedContext = normalizeTerminalContextForThread(threadId, context); | |
| 2496 | + if (!normalizedContext) { | |
| 2497 | + return state; | |
| 2498 | + } | |
| 2499 | + const dedupKey = terminalContextDedupKey(normalizedContext); | |
| 2500 | + if ( | |
| 2501 | + existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) || | |
| 2502 | + existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey) | |
| 2503 | + ) { | |
| 2504 | + return state; | |
| 2505 | + } | |
| 2506 | + inserted = true; | |
| 2507 | + const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index)); | |
| 2508 | + const nextDraft: ComposerThreadDraftState = { | |
| 2509 | + ...existing, | |
| 2510 | + prompt, | |
| 2511 | + terminalContexts: [ | |
| 2512 | + ...existing.terminalContexts.slice(0, boundedIndex), | |
| 2513 | + normalizedContext, | |
| 2514 | + ...existing.terminalContexts.slice(boundedIndex), | |
| 2515 | + ], | |
| 2516 | + }; | |
| 2517 | + return { | |
| 2518 | + draftsByThreadKey: { | |
| 2519 | + ...state.draftsByThreadKey, | |
| 2520 | + [threadKey]: nextDraft, | |
| 2003 | 2521 | }, |
| 2004 | − }, | |
| 2005 | − }; | |
| 2006 | − }); | |
| 2007 | − }, | |
| 2008 | − removeTerminalContext: (threadId, contextId) => { | |
| 2009 | − if (threadId.length === 0 || contextId.length === 0) { | |
| 2010 | − return; | |
| 2011 | − } | |
| 2012 | − set((state) => { | |
| 2013 | − const current = state.draftsByThreadId[threadId]; | |
| 2014 | − if (!current) { | |
| 2015 | − return state; | |
| 2016 | − } | |
| 2017 | − const nextDraft: ComposerThreadDraftState = { | |
| 2018 | − ...current, | |
| 2019 | − terminalContexts: current.terminalContexts.filter( | |
| 2020 | − (context) => context.id !== contextId, | |
| 2021 | − ), | |
| 2022 | − }; | |
| 2023 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 2024 | − if (shouldRemoveDraft(nextDraft)) { | |
| 2025 | − delete nextDraftsByThreadId[threadId]; | |
| 2026 | − } else { | |
| 2027 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 2028 | − } | |
| 2029 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 2030 | − }); | |
| 2031 | − }, | |
| 2032 | − clearTerminalContexts: (threadId) => { | |
| 2033 | − if (threadId.length === 0) { | |
| 2034 | − return; | |
| 2035 | − } | |
| 2036 | − set((state) => { | |
| 2037 | − const current = state.draftsByThreadId[threadId]; | |
| 2038 | − if (!current || current.terminalContexts.length === 0) { | |
| 2039 | − return state; | |
| 2040 | − } | |
| 2041 | − const nextDraft: ComposerThreadDraftState = { | |
| 2042 | − ...current, | |
| 2043 | − terminalContexts: [], | |
| 2044 | − }; | |
| 2045 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 2046 | − if (shouldRemoveDraft(nextDraft)) { | |
| 2047 | − delete nextDraftsByThreadId[threadId]; | |
| 2048 | − } else { | |
| 2049 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 2522 | + }; | |
| 2523 | + }); | |
| 2524 | + return inserted; | |
| 2525 | + }, | |
| 2526 | + addTerminalContext: (threadRef, context) => { | |
| 2527 | + const threadKey = resolveComposerDraftKey(get(), threadRef); | |
| 2528 | + const threadId = resolveComposerThreadId(get(), threadRef); | |
| 2529 | + if (!threadKey || !threadId) { | |
| 2530 | + return; | |
| 2050 | 2531 | } |
| 2051 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 2052 | − }); | |
| 2053 | − }, | |
| 2054 | − clearPersistedAttachments: (threadId) => { | |
| 2055 | − if (threadId.length === 0) { | |
| 2056 | − return; | |
| 2057 | − } | |
| 2058 | − set((state) => { | |
| 2059 | − const current = state.draftsByThreadId[threadId]; | |
| 2060 | − if (!current) { | |
| 2061 | − return state; | |
| 2532 | + get().addTerminalContexts( | |
| 2533 | + typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef, | |
| 2534 | + [context], | |
| 2535 | + ); | |
| 2536 | + }, | |
| 2537 | + addTerminalContexts: (threadRef, contexts) => { | |
| 2538 | + const threadKey = resolveComposerDraftKey(get(), threadRef); | |
| 2539 | + const threadId = resolveComposerThreadId(get(), threadRef); | |
| 2540 | + if (!threadKey || !threadId || contexts.length === 0) { | |
| 2541 | + return; | |
| 2062 | 2542 | } |
| 2063 | − const nextDraft: ComposerThreadDraftState = { | |
| 2064 | − ...current, | |
| 2065 | − persistedAttachments: [], | |
| 2066 | − nonPersistedImageIds: [], | |
| 2067 | − }; | |
| 2068 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 2069 | − if (shouldRemoveDraft(nextDraft)) { | |
| 2070 | − delete nextDraftsByThreadId[threadId]; | |
| 2071 | − } else { | |
| 2072 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 2543 | + set((state) => { | |
| 2544 | + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); | |
| 2545 | + const acceptedContexts = normalizeTerminalContextsForThread(threadId, [ | |
| 2546 | + ...existing.terminalContexts, | |
| 2547 | + ...contexts, | |
| 2548 | + ]).slice(existing.terminalContexts.length); | |
| 2549 | + if (acceptedContexts.length === 0) { | |
| 2550 | + return state; | |
| 2551 | + } | |
| 2552 | + return { | |
| 2553 | + draftsByThreadKey: { | |
| 2554 | + ...state.draftsByThreadKey, | |
| 2555 | + [threadKey]: { | |
| 2556 | + ...existing, | |
| 2557 | + prompt: ensureInlineTerminalContextPlaceholders( | |
| 2558 | + existing.prompt, | |
| 2559 | + existing.terminalContexts.length + acceptedContexts.length, | |
| 2560 | + ), | |
| 2561 | + terminalContexts: [...existing.terminalContexts, ...acceptedContexts], | |
| 2562 | + }, | |
| 2563 | + }, | |
| 2564 | + }; | |
| 2565 | + }); | |
| 2566 | + }, | |
| 2567 | + removeTerminalContext: (threadRef, contextId) => { | |
| 2568 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2569 | + if (threadKey.length === 0 || contextId.length === 0) { | |
| 2570 | + return; | |
| 2073 | 2571 | } |
| 2074 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 2075 | − }); | |
| 2076 | − }, | |
| 2077 | − syncPersistedAttachments: (threadId, attachments) => { | |
| 2078 | − if (threadId.length === 0) { | |
| 2079 | − return; | |
| 2080 | − } | |
| 2081 | − const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id)); | |
| 2082 | − set((state) => { | |
| 2083 | − const current = state.draftsByThreadId[threadId]; | |
| 2084 | − if (!current) { | |
| 2085 | − return state; | |
| 2572 | + set((state) => { | |
| 2573 | + const current = state.draftsByThreadKey[threadKey]; | |
| 2574 | + if (!current) { | |
| 2575 | + return state; | |
| 2576 | + } | |
| 2577 | + const nextDraft: ComposerThreadDraftState = { | |
| 2578 | + ...current, | |
| 2579 | + terminalContexts: current.terminalContexts.filter( | |
| 2580 | + (context) => context.id !== contextId, | |
| 2581 | + ), | |
| 2582 | + }; | |
| 2583 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2584 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2585 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2586 | + } else { | |
| 2587 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2588 | + } | |
| 2589 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2590 | + }); | |
| 2591 | + }, | |
| 2592 | + clearTerminalContexts: (threadRef) => { | |
| 2593 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2594 | + if (threadKey.length === 0) { | |
| 2595 | + return; | |
| 2086 | 2596 | } |
| 2087 | − const nextDraft: ComposerThreadDraftState = { | |
| 2088 | − ...current, | |
| 2089 | − // Stage attempted attachments so persist middleware can try writing them. | |
| 2090 | − persistedAttachments: attachments, | |
| 2091 | − nonPersistedImageIds: current.nonPersistedImageIds.filter( | |
| 2092 | − (id) => !attachmentIdSet.has(id), | |
| 2093 | − ), | |
| 2094 | − }; | |
| 2095 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 2096 | − if (shouldRemoveDraft(nextDraft)) { | |
| 2097 | − delete nextDraftsByThreadId[threadId]; | |
| 2098 | − } else { | |
| 2099 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 2597 | + set((state) => { | |
| 2598 | + const current = state.draftsByThreadKey[threadKey]; | |
| 2599 | + if (!current || current.terminalContexts.length === 0) { | |
| 2600 | + return state; | |
| 2601 | + } | |
| 2602 | + const nextDraft: ComposerThreadDraftState = { | |
| 2603 | + ...current, | |
| 2604 | + terminalContexts: [], | |
| 2605 | + }; | |
| 2606 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2607 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2608 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2609 | + } else { | |
| 2610 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2611 | + } | |
| 2612 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2613 | + }); | |
| 2614 | + }, | |
| 2615 | + clearPersistedAttachments: (threadRef) => { | |
| 2616 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2617 | + if (threadKey.length === 0) { | |
| 2618 | + return; | |
| 2100 | 2619 | } |
| 2101 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 2102 | − }); | |
| 2103 | − Promise.resolve().then(() => { | |
| 2104 | − verifyPersistedAttachments(threadId, attachments, set); | |
| 2105 | − }); | |
| 2106 | − }, | |
| 2107 | − clearComposerContent: (threadId) => { | |
| 2108 | − if (threadId.length === 0) { | |
| 2109 | − return; | |
| 2110 | − } | |
| 2111 | − set((state) => { | |
| 2112 | − const current = state.draftsByThreadId[threadId]; | |
| 2113 | − if (!current) { | |
| 2114 | − return state; | |
| 2620 | + set((state) => { | |
| 2621 | + const current = state.draftsByThreadKey[threadKey]; | |
| 2622 | + if (!current) { | |
| 2623 | + return state; | |
| 2624 | + } | |
| 2625 | + const nextDraft: ComposerThreadDraftState = { | |
| 2626 | + ...current, | |
| 2627 | + persistedAttachments: [], | |
| 2628 | + nonPersistedImageIds: [], | |
| 2629 | + }; | |
| 2630 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2631 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2632 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2633 | + } else { | |
| 2634 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2635 | + } | |
| 2636 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2637 | + }); | |
| 2638 | + }, | |
| 2639 | + syncPersistedAttachments: (threadRef, attachments) => { | |
| 2640 | + const threadKey = resolveComposerDraftKey(get(), threadRef); | |
| 2641 | + if (!threadKey) { | |
| 2642 | + return; | |
| 2115 | 2643 | } |
| 2116 | − const nextDraft: ComposerThreadDraftState = { | |
| 2117 | − ...current, | |
| 2118 | − prompt: "", | |
| 2119 | − images: [], | |
| 2120 | − nonPersistedImageIds: [], | |
| 2121 | − persistedAttachments: [], | |
| 2122 | − terminalContexts: [], | |
| 2123 | − }; | |
| 2124 | − const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 2125 | − if (shouldRemoveDraft(nextDraft)) { | |
| 2126 | − delete nextDraftsByThreadId[threadId]; | |
| 2127 | − } else { | |
| 2128 | − nextDraftsByThreadId[threadId] = nextDraft; | |
| 2644 | + const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id)); | |
| 2645 | + set((state) => { | |
| 2646 | + const current = state.draftsByThreadKey[threadKey]; | |
| 2647 | + if (!current) { | |
| 2648 | + return state; | |
| 2649 | + } | |
| 2650 | + const nextDraft: ComposerThreadDraftState = { | |
| 2651 | + ...current, | |
| 2652 | + // Stage attempted attachments so persist middleware can try writing them. | |
| 2653 | + persistedAttachments: attachments, | |
| 2654 | + nonPersistedImageIds: current.nonPersistedImageIds.filter( | |
| 2655 | + (id) => !attachmentIdSet.has(id), | |
| 2656 | + ), | |
| 2657 | + }; | |
| 2658 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2659 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2660 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2661 | + } else { | |
| 2662 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2663 | + } | |
| 2664 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2665 | + }); | |
| 2666 | + Promise.resolve().then(() => { | |
| 2667 | + verifyPersistedAttachments(threadKey, attachments, set); | |
| 2668 | + }); | |
| 2669 | + }, | |
| 2670 | + clearComposerContent: (threadRef) => { | |
| 2671 | + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | |
| 2672 | + if (threadKey.length === 0) { | |
| 2673 | + return; | |
| 2129 | 2674 | } |
| 2130 | − return { draftsByThreadId: nextDraftsByThreadId }; | |
| 2131 | − }); | |
| 2132 | − }, | |
| 2133 | − }), | |
| 2675 | + set((state) => { | |
| 2676 | + const current = state.draftsByThreadKey[threadKey]; | |
| 2677 | + if (!current) { | |
| 2678 | + return state; | |
| 2679 | + } | |
| 2680 | + const nextDraft: ComposerThreadDraftState = { | |
| 2681 | + ...current, | |
| 2682 | + prompt: "", | |
| 2683 | + images: [], | |
| 2684 | + nonPersistedImageIds: [], | |
| 2685 | + persistedAttachments: [], | |
| 2686 | + terminalContexts: [], | |
| 2687 | + }; | |
| 2688 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 2689 | + if (shouldRemoveDraft(nextDraft)) { | |
| 2690 | + delete nextDraftsByThreadKey[threadKey]; | |
| 2691 | + } else { | |
| 2692 | + nextDraftsByThreadKey[threadKey] = nextDraft; | |
| 2693 | + } | |
| 2694 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 2695 | + }); | |
| 2696 | + }, | |
| 2697 | + }; | |
| 2698 | + }, | |
| 2134 | 2699 | { |
| 2135 | 2700 | name: COMPOSER_DRAFT_STORAGE_KEY, |
| 2136 | 2701 | version: COMPOSER_DRAFT_STORAGE_VERSION, |
| @@ -2140,17 +2705,23 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( · 3 unmodified lines | ||
| 2140 | 2705 | merge: (persistedState, currentState) => { |
| 2141 | 2706 | const normalizedPersisted = |
| 2142 | 2707 | normalizeCurrentPersistedComposerDraftStoreState(persistedState); |
| 2143 | − const draftsByThreadId = Object.fromEntries( | |
| 2144 | − Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [ | |
| 2145 | − threadId, | |
| 2708 | + const draftsByThreadKey = Object.fromEntries( | |
| 2709 | + Object.entries(normalizedPersisted.draftsByThreadKey).map(([threadKey, draft]) => [ | |
| 2710 | + threadKey, | |
| 2146 | 2711 | toHydratedThreadDraft(draft), |
| 2147 | 2712 | ]), |
| 2148 | 2713 | ); |
| 2714 | + const draftThreadsByThreadKey = Object.fromEntries( | |
| 2715 | + Object.entries(normalizedPersisted.draftThreadsByThreadKey).map( | |
| 2716 | + ([threadKey, draftThread]) => [threadKey, toHydratedDraftThreadState(draftThread)], | |
| 2717 | + ), | |
| 2718 | + ) as Record<string, DraftThreadState>; | |
| 2149 | 2719 | return { |
| 2150 | 2720 | ...currentState, |
| 2151 | − draftsByThreadId, | |
| 2152 | − draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, | |
| 2153 | − projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, | |
| 2721 | + draftsByThreadKey, | |
| 2722 | + draftThreadsByThreadKey, | |
| 2723 | + logicalProjectDraftThreadKeyByLogicalProjectKey: | |
| 2724 | + normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey, | |
| 2154 | 2725 | stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, |
| 2155 | 2726 | stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, |
| 2156 | 2727 | }; |
| @@ -2159,19 +2730,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( · 2 unmodified lines | ||
| 2159 | 2730 | ), |
| 2160 | 2731 | ); |
| 2161 | 2732 | |
| 2162 | −export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { | |
| 2163 | − return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); | |
| 2733 | +export const useComposerDraftStore = composerDraftStore; | |
| 2734 | + | |
| 2735 | +export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState { | |
| 2736 | + return useComposerDraftStore((state) => { | |
| 2737 | + return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT; | |
| 2738 | + }); | |
| 2164 | 2739 | } |
| 2165 | 2740 | |
| 2166 | 2741 | export function useEffectiveComposerModelState(input: { |
| 2167 | − threadId: ThreadId; | |
| 2742 | + threadRef?: ComposerThreadTarget; | |
| 2743 | + draftId?: DraftId; | |
| 2168 | 2744 | providers: ReadonlyArray<ServerProvider>; |
| 2169 | 2745 | selectedProvider: ProviderKind; |
| 2170 | 2746 | threadModelSelection: ModelSelection | null | undefined; |
| 2171 | 2747 | projectModelSelection: ModelSelection | null | undefined; |
| 2172 | 2748 | settings: UnifiedSettings; |
| 2173 | 2749 | }): EffectiveComposerModelState { |
| 2174 | − const draft = useComposerThreadDraft(input.threadId); | |
| 2750 | + const draft = useComposerThreadDraft(input.threadRef ?? input.draftId ?? DraftId.makeUnsafe("")); | |
| 2175 | 2751 | |
| 2176 | 2752 | return useMemo( |
| 2177 | 2753 | () => |
| @@ -2195,21 +2771,69 @@ export function useEffectiveComposerModelState(input: { · 17 unmodified lines | ||
| 2195 | 2771 | } |
| 2196 | 2772 | |
| 2197 | 2773 | /** |
| 2198 | − * Clear a draft thread once the server has materialized the same thread id. | |
| 2774 | + * Mark a draft thread as promoting once the server has materialized the same thread id. | |
| 2199 | 2775 | * |
| 2200 | 2776 | * Use the single-thread helper for live `thread.created` events and the |
| 2201 | 2777 | * iterable helper for bootstrap/recovery paths that discover multiple server |
| 2202 | 2778 | * threads at once. |
| 2203 | 2779 | */ |
| 2204 | −export function clearPromotedDraftThread(threadId: ThreadId): void { | |
| 2205 | − if (!useComposerDraftStore.getState().getDraftThread(threadId)) { | |
| 2780 | +export function markPromotedDraftThread(threadId: ThreadId): void { | |
| 2781 | + const store = useComposerDraftStore.getState(); | |
| 2782 | + const draftThreadTargets: ComposerThreadTarget[] = []; | |
| 2783 | + for (const [draftId, draftThread] of Object.entries(store.draftThreadsByThreadKey)) { | |
| 2784 | + if (draftThread.threadId === threadId) { | |
| 2785 | + draftThreadTargets.push(DraftId.makeUnsafe(draftId)); | |
| 2786 | + } | |
| 2787 | + } | |
| 2788 | + if (draftThreadTargets.length === 0) { | |
| 2206 | 2789 | return; |
| 2207 | 2790 | } |
| 2208 | − useComposerDraftStore.getState().clearDraftThread(threadId); | |
| 2791 | + for (const draftThreadTarget of draftThreadTargets) { | |
| 2792 | + store.markDraftThreadPromoting(draftThreadTarget); | |
| 2793 | + } | |
| 2794 | +} | |
| 2795 | + | |
| 2796 | +export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { | |
| 2797 | + const draftStore = useComposerDraftStore.getState(); | |
| 2798 | + for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { | |
| 2799 | + if ( | |
| 2800 | + draftThread.environmentId === threadRef.environmentId && | |
| 2801 | + draftThread.threadId === threadRef.threadId | |
| 2802 | + ) { | |
| 2803 | + draftStore.markDraftThreadPromoting(DraftId.makeUnsafe(draftId), threadRef); | |
| 2804 | + } | |
| 2805 | + } | |
| 2209 | 2806 | } |
| 2210 | 2807 | |
| 2211 | −export function clearPromotedDraftThreads(serverThreadIds: Iterable<ThreadId>): void { | |
| 2808 | +export function markPromotedDraftThreads(serverThreadIds: Iterable<ThreadId>): void { | |
| 2212 | 2809 | for (const threadId of serverThreadIds) { |
| 2213 | − clearPromotedDraftThread(threadId); | |
| 2810 | + markPromotedDraftThread(threadId); | |
| 2811 | + } | |
| 2812 | +} | |
| 2813 | + | |
| 2814 | +export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable<ScopedThreadRef>): void { | |
| 2815 | + for (const threadRef of serverThreadRefs) { | |
| 2816 | + markPromotedDraftThreadByRef(threadRef); | |
| 2817 | + } | |
| 2818 | +} | |
| 2819 | + | |
| 2820 | +export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { | |
| 2821 | + const draftStore = useComposerDraftStore.getState(); | |
| 2822 | + for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { | |
| 2823 | + if ( | |
| 2824 | + draftThread.promotedTo && | |
| 2825 | + draftThread.promotedTo.environmentId === threadRef.environmentId && | |
| 2826 | + draftThread.promotedTo.threadId === threadRef.threadId | |
| 2827 | + ) { | |
| 2828 | + draftStore.finalizePromotedDraftThread(DraftId.makeUnsafe(draftId)); | |
| 2829 | + } | |
| 2830 | + } | |
| 2831 | +} | |
| 2832 | + | |
| 2833 | +export function finalizePromotedDraftThreadsByRef( | |
| 2834 | + serverThreadRefs: Iterable<ScopedThreadRef>, | |
| 2835 | +): void { | |
| 2836 | + for (const threadRef of serverThreadRefs) { | |
| 2837 | + finalizePromotedDraftThreadByRef(threadRef); | |
| 2214 | 2838 | } |
| 2215 | 2839 | } |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { EDITORS, EditorId, NativeApi } from "@t3tools/contracts"; | |
| 1 | +import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts"; | |
| 2 | 2 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; |
| 3 | 3 | import { useMemo } from "react"; |
| 4 | 4 | |
| @@ -26,7 +26,7 @@ export function resolveAndPersistPreferredEditor( · 21 unmodified lines | ||
| 26 | 26 | return editor ?? null; |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | −export async function openInPreferredEditor(api: NativeApi, targetPath: string): Promise<EditorId> { | |
| 29 | +export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise<EditorId> { | |
| 30 | 30 | const { availableEditors } = await api.server.getConfig(); |
| 31 | 31 | const editor = resolveAndPersistPreferredEditor(availableEditors); |
| 32 | 32 | if (!editor) throw new Error("No available editors found."); |
| @@ -0,0 +1,67 @@ | ||
| 1 | +import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; | |
| 2 | + | |
| 3 | +import { readWsRpcClientEntryForEnvironment, WsRpcClient } from "./wsRpcClient"; | |
| 4 | + | |
| 5 | +export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { | |
| 6 | + return { | |
| 7 | + terminal: { | |
| 8 | + open: (input) => rpcClient.terminal.open(input as never), | |
| 9 | + write: (input) => rpcClient.terminal.write(input as never), | |
| 10 | + resize: (input) => rpcClient.terminal.resize(input as never), | |
| 11 | + clear: (input) => rpcClient.terminal.clear(input as never), | |
| 12 | + restart: (input) => rpcClient.terminal.restart(input as never), | |
| 13 | + close: (input) => rpcClient.terminal.close(input as never), | |
| 14 | + onEvent: (callback) => rpcClient.terminal.onEvent(callback), | |
| 15 | + }, | |
| 16 | + projects: { | |
| 17 | + searchEntries: rpcClient.projects.searchEntries, | |
| 18 | + writeFile: rpcClient.projects.writeFile, | |
| 19 | + }, | |
| 20 | + git: { | |
| 21 | + pull: rpcClient.git.pull, | |
| 22 | + refreshStatus: rpcClient.git.refreshStatus, | |
| 23 | + onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), | |
| 24 | + listBranches: rpcClient.git.listBranches, | |
| 25 | + createWorktree: rpcClient.git.createWorktree, | |
| 26 | + removeWorktree: rpcClient.git.removeWorktree, | |
| 27 | + createBranch: rpcClient.git.createBranch, | |
| 28 | + checkout: rpcClient.git.checkout, | |
| 29 | + init: rpcClient.git.init, | |
| 30 | + resolvePullRequest: rpcClient.git.resolvePullRequest, | |
| 31 | + preparePullRequestThread: rpcClient.git.preparePullRequestThread, | |
| 32 | + }, | |
| 33 | + orchestration: { | |
| 34 | + getSnapshot: rpcClient.orchestration.getSnapshot, | |
| 35 | + dispatchCommand: rpcClient.orchestration.dispatchCommand, | |
| 36 | + getTurnDiff: rpcClient.orchestration.getTurnDiff, | |
| 37 | + getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, | |
| 38 | + replayEvents: (fromSequenceExclusive) => | |
| 39 | + rpcClient.orchestration | |
| 40 | + .replayEvents({ fromSequenceExclusive }) | |
| 41 | + .then((events) => [...events]), | |
| 42 | + onDomainEvent: (callback, options) => | |
| 43 | + rpcClient.orchestration.onDomainEvent(callback, options), | |
| 44 | + }, | |
| 45 | + }; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi | undefined { | |
| 49 | + if (typeof window === "undefined") { | |
| 50 | + return undefined; | |
| 51 | + } | |
| 52 | + | |
| 53 | + if (!environmentId) { | |
| 54 | + return undefined; | |
| 55 | + } | |
| 56 | + | |
| 57 | + const entry = readWsRpcClientEntryForEnvironment(environmentId); | |
| 58 | + return entry ? createEnvironmentApi(entry.client) : undefined; | |
| 59 | +} | |
| 60 | + | |
| 61 | +export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi { | |
| 62 | + const api = readEnvironmentApi(environmentId); | |
| 63 | + if (!api) { | |
| 64 | + throw new Error(`Environment API not found for environment ${environmentId}`); | |
| 65 | + } | |
| 66 | + return api; | |
| 67 | +} | |
| @@ -0,0 +1,65 @@ | ||
| 1 | +import { | |
| 2 | + createKnownEnvironmentFromWsUrl, | |
| 3 | + getKnownEnvironmentBaseUrl, | |
| 4 | + type KnownEnvironment, | |
| 5 | +} from "@t3tools/client-runtime"; | |
| 6 | +import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | |
| 7 | + | |
| 8 | +function createKnownEnvironmentFromDesktopBootstrap( | |
| 9 | + bootstrap: DesktopEnvironmentBootstrap | null | undefined, | |
| 10 | +): KnownEnvironment | null { | |
| 11 | + if (!bootstrap?.wsUrl) { | |
| 12 | + return null; | |
| 13 | + } | |
| 14 | + | |
| 15 | + return createKnownEnvironmentFromWsUrl({ | |
| 16 | + id: `desktop:${bootstrap.label}`, | |
| 17 | + label: bootstrap.label, | |
| 18 | + source: "desktop-managed", | |
| 19 | + wsUrl: bootstrap.wsUrl, | |
| 20 | + }); | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function getPrimaryKnownEnvironment(): KnownEnvironment | null { | |
| 24 | + const desktopEnvironment = createKnownEnvironmentFromDesktopBootstrap( | |
| 25 | + window.desktopBridge?.getLocalEnvironmentBootstrap(), | |
| 26 | + ); | |
| 27 | + if (desktopEnvironment) { | |
| 28 | + return desktopEnvironment; | |
| 29 | + } | |
| 30 | + | |
| 31 | + const legacyDesktopWsUrl = window.desktopBridge?.getWsUrl(); | |
| 32 | + if (typeof legacyDesktopWsUrl === "string" && legacyDesktopWsUrl.length > 0) { | |
| 33 | + return createKnownEnvironmentFromWsUrl({ | |
| 34 | + id: "desktop-legacy", | |
| 35 | + label: "Local environment", | |
| 36 | + source: "desktop-managed", | |
| 37 | + wsUrl: legacyDesktopWsUrl, | |
| 38 | + }); | |
| 39 | + } | |
| 40 | + | |
| 41 | + const configuredWsUrl = import.meta.env.VITE_WS_URL; | |
| 42 | + if (typeof configuredWsUrl === "string" && configuredWsUrl.length > 0) { | |
| 43 | + return createKnownEnvironmentFromWsUrl({ | |
| 44 | + id: "configured-primary", | |
| 45 | + label: "Primary environment", | |
| 46 | + source: "configured", | |
| 47 | + wsUrl: configuredWsUrl, | |
| 48 | + }); | |
| 49 | + } | |
| 50 | + | |
| 51 | + return createKnownEnvironmentFromWsUrl({ | |
| 52 | + id: "window-origin", | |
| 53 | + label: "Primary environment", | |
| 54 | + source: "window-origin", | |
| 55 | + wsUrl: window.location.origin, | |
| 56 | + }); | |
| 57 | +} | |
| 58 | + | |
| 59 | +export function resolvePrimaryEnvironmentBootstrapUrl(): string { | |
| 60 | + const baseUrl = getKnownEnvironmentBaseUrl(getPrimaryKnownEnvironment()); | |
| 61 | + if (!baseUrl) { | |
| 62 | + throw new Error("Unable to resolve a known environment bootstrap URL."); | |
| 63 | + } | |
| 64 | + return baseUrl; | |
| 65 | +} | |
| @@ -1,5 +1,6 @@ | ||
| 1 | −import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 2 | −import { useNavigate, useParams } from "@tanstack/react-router"; | |
| 1 | +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; | |
| 2 | +import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; | |
| 3 | +import { useParams, useRouter } from "@tanstack/react-router"; | |
| 3 | 4 | import { useCallback, useMemo } from "react"; |
| 4 | 5 | import { useShallow } from "zustand/react/shallow"; |
| 5 | 6 | import { |
| @@ -7,37 +8,49 @@ import { · 1 unmodified line | ||
| 7 | 8 | type DraftThreadState, |
| 8 | 9 | useComposerDraftStore, |
| 9 | 10 | } from "../composerDraftStore"; |
| 10 | −import { newThreadId } from "../lib/utils"; | |
| 11 | +import { newDraftId, newThreadId } from "../lib/utils"; | |
| 11 | 12 | import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; |
| 12 | −import { useStore } from "../store"; | |
| 13 | −import { createThreadSelector } from "../storeSelectors"; | |
| 13 | +import { deriveLogicalProjectKey } from "../logicalProject"; | |
| 14 | +import { selectProjectsAcrossEnvironments, useStore } from "../store"; | |
| 15 | +import { createThreadSelectorByRef } from "../storeSelectors"; | |
| 16 | +import { resolveThreadRouteTarget } from "../threadRoutes"; | |
| 14 | 17 | import { useUiStateStore } from "../uiStateStore"; |
| 15 | 18 | |
| 16 | 19 | export function useHandleNewThread() { |
| 17 | − const projectIds = useStore(useShallow((store) => store.projectIds)); | |
| 20 | + const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); | |
| 18 | 21 | const projectOrder = useUiStateStore((store) => store.projectOrder); |
| 19 | − const navigate = useNavigate(); | |
| 20 | − const routeThreadId = useParams({ | |
| 22 | + const router = useRouter(); | |
| 23 | + const routeTarget = useParams({ | |
| 21 | 24 | strict: false, |
| 22 | − select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | |
| 25 | + select: (params) => resolveThreadRouteTarget(params), | |
| 23 | 26 | }); |
| 27 | + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; | |
| 24 | 28 | const activeThread = useStore( |
| 25 | − useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]), | |
| 29 | + useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), | |
| 26 | 30 | ); |
| 27 | − const activeDraftThread = useComposerDraftStore((store) => | |
| 28 | − routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, | |
| 31 | + const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); | |
| 32 | + const activeDraftThread = useComposerDraftStore(() => | |
| 33 | + routeTarget | |
| 34 | + ? routeTarget.kind === "server" | |
| 35 | + ? getDraftThread(routeTarget.threadRef) | |
| 36 | + : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) | |
| 37 | + : null, | |
| 29 | 38 | ); |
| 30 | 39 | const orderedProjects = useMemo(() => { |
| 31 | 40 | return orderItemsByPreferredIds({ |
| 32 | − items: projectIds, | |
| 41 | + items: projects, | |
| 33 | 42 | preferredIds: projectOrder, |
| 34 | − getId: (projectId) => projectId, | |
| 43 | + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 35 | 44 | }); |
| 36 | − }, [projectIds, projectOrder]); | |
| 45 | + }, [projectOrder, projects]); | |
| 46 | + const getCurrentRouteTarget = useCallback(() => { | |
| 47 | + const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; | |
| 48 | + return resolveThreadRouteTarget(currentRouteParams); | |
| 49 | + }, [router]); | |
| 37 | 50 | |
| 38 | 51 | const handleNewThread = useCallback( |
| 39 | 52 | ( |
| 40 | − projectId: ProjectId, | |
| 53 | + projectRef: ScopedProjectRef, | |
| 41 | 54 | options?: { |
| 42 | 55 | branch?: string | null; |
| 43 | 56 | worktreePath?: string | null; |
| @@ -45,84 +58,110 @@ export function useHandleNewThread() { · 1 unmodified line | ||
| 45 | 58 | }, |
| 46 | 59 | ): Promise<void> => { |
| 47 | 60 | const { |
| 48 | − clearProjectDraftThreadId, | |
| 61 | + getDraftSessionByLogicalProjectKey, | |
| 62 | + getDraftSession, | |
| 49 | 63 | getDraftThread, |
| 50 | − getDraftThreadByProjectId, | |
| 51 | 64 | applyStickyState, |
| 52 | 65 | setDraftThreadContext, |
| 53 | − setProjectDraftThreadId, | |
| 66 | + setLogicalProjectDraftThreadId, | |
| 54 | 67 | } = useComposerDraftStore.getState(); |
| 68 | + const currentRouteTarget = getCurrentRouteTarget(); | |
| 69 | + const project = projects.find( | |
| 70 | + (candidate) => | |
| 71 | + candidate.id === projectRef.projectId && | |
| 72 | + candidate.environmentId === projectRef.environmentId, | |
| 73 | + ); | |
| 74 | + const logicalProjectKey = project | |
| 75 | + ? deriveLogicalProjectKey(project) | |
| 76 | + : scopedProjectKey(projectRef); | |
| 55 | 77 | const hasBranchOption = options?.branch !== undefined; |
| 56 | 78 | const hasWorktreePathOption = options?.worktreePath !== undefined; |
| 57 | 79 | const hasEnvModeOption = options?.envMode !== undefined; |
| 58 | − const storedDraftThread = getDraftThreadByProjectId(projectId); | |
| 59 | − const latestActiveDraftThread: DraftThreadState | null = routeThreadId | |
| 60 | − ? getDraftThread(routeThreadId) | |
| 80 | + const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); | |
| 81 | + const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget | |
| 82 | + ? currentRouteTarget.kind === "server" | |
| 83 | + ? getDraftThread(currentRouteTarget.threadRef) | |
| 84 | + : getDraftSession(currentRouteTarget.draftId) | |
| 61 | 85 | : null; |
| 62 | 86 | if (storedDraftThread) { |
| 63 | 87 | return (async () => { |
| 64 | 88 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { |
| 65 | − setDraftThreadContext(storedDraftThread.threadId, { | |
| 89 | + setDraftThreadContext(storedDraftThread.draftId, { | |
| 66 | 90 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), |
| 67 | 91 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), |
| 68 | 92 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), |
| 69 | 93 | }); |
| 70 | 94 | } |
| 71 | − setProjectDraftThreadId(projectId, storedDraftThread.threadId); | |
| 72 | − if (routeThreadId === storedDraftThread.threadId) { | |
| 95 | + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, storedDraftThread.draftId, { | |
| 96 | + threadId: storedDraftThread.threadId, | |
| 97 | + }); | |
| 98 | + if ( | |
| 99 | + currentRouteTarget?.kind === "draft" && | |
| 100 | + currentRouteTarget.draftId === storedDraftThread.draftId | |
| 101 | + ) { | |
| 73 | 102 | return; |
| 74 | 103 | } |
| 75 | − await navigate({ | |
| 76 | − to: "/$threadId", | |
| 77 | − params: { threadId: storedDraftThread.threadId }, | |
| 104 | + await router.navigate({ | |
| 105 | + to: "/draft/$draftId", | |
| 106 | + params: { draftId: storedDraftThread.draftId }, | |
| 78 | 107 | }); |
| 79 | 108 | })(); |
| 80 | 109 | } |
| 81 | 110 | |
| 82 | − clearProjectDraftThreadId(projectId); | |
| 83 | − | |
| 84 | 111 | if ( |
| 85 | 112 | latestActiveDraftThread && |
| 86 | − routeThreadId && | |
| 87 | − latestActiveDraftThread.projectId === projectId | |
| 113 | + currentRouteTarget?.kind === "draft" && | |
| 114 | + latestActiveDraftThread.logicalProjectKey === logicalProjectKey | |
| 88 | 115 | ) { |
| 89 | 116 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { |
| 90 | − setDraftThreadContext(routeThreadId, { | |
| 117 | + setDraftThreadContext(currentRouteTarget.draftId, { | |
| 91 | 118 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), |
| 92 | 119 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), |
| 93 | 120 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), |
| 94 | 121 | }); |
| 95 | 122 | } |
| 96 | − setProjectDraftThreadId(projectId, routeThreadId); | |
| 123 | + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { | |
| 124 | + threadId: latestActiveDraftThread.threadId, | |
| 125 | + createdAt: latestActiveDraftThread.createdAt, | |
| 126 | + runtimeMode: latestActiveDraftThread.runtimeMode, | |
| 127 | + interactionMode: latestActiveDraftThread.interactionMode, | |
| 128 | + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | |
| 129 | + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | |
| 130 | + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | |
| 131 | + }); | |
| 97 | 132 | return Promise.resolve(); |
| 98 | 133 | } |
| 99 | 134 | |
| 135 | + const draftId = newDraftId(); | |
| 100 | 136 | const threadId = newThreadId(); |
| 101 | 137 | const createdAt = new Date().toISOString(); |
| 102 | 138 | return (async () => { |
| 103 | − setProjectDraftThreadId(projectId, threadId, { | |
| 139 | + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { | |
| 140 | + threadId, | |
| 104 | 141 | createdAt, |
| 105 | 142 | branch: options?.branch ?? null, |
| 106 | 143 | worktreePath: options?.worktreePath ?? null, |
| 107 | 144 | envMode: options?.envMode ?? "local", |
| 108 | 145 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 109 | 146 | }); |
| 110 | − applyStickyState(threadId); | |
| 147 | + applyStickyState(draftId); | |
| 111 | 148 | |
| 112 | − await navigate({ | |
| 113 | − to: "/$threadId", | |
| 114 | − params: { threadId }, | |
| 149 | + await router.navigate({ | |
| 150 | + to: "/draft/$draftId", | |
| 151 | + params: { draftId }, | |
| 115 | 152 | }); |
| 116 | 153 | })(); |
| 117 | 154 | }, |
| 118 | − [navigate, routeThreadId], | |
| 155 | + [getCurrentRouteTarget, router, projects], | |
| 119 | 156 | ); |
| 120 | 157 | |
| 121 | 158 | return { |
| 122 | 159 | activeDraftThread, |
| 123 | 160 | activeThread, |
| 124 | − defaultProjectId: orderedProjects[0] ?? null, | |
| 161 | + defaultProjectRef: orderedProjects[0] | |
| 162 | + ? scopeProjectRef(orderedProjects[0].environmentId, orderedProjects[0].id) | |
| 163 | + : null, | |
| 125 | 164 | handleNewThread, |
| 126 | − routeThreadId, | |
| 165 | + routeThreadRef, | |
| 127 | 166 | }; |
| 128 | 167 | } |
| @@ -26,7 +26,7 @@ import { · 25 unmodified lines | ||
| 26 | 26 | TimestampFormat, |
| 27 | 27 | UnifiedSettings, |
| 28 | 28 | } from "@t3tools/contracts/settings"; |
| 29 | −import { ensureNativeApi } from "~/nativeApi"; | |
| 29 | +import { ensureLocalApi } from "~/localApi"; | |
| 30 | 30 | import { useLocalStorage } from "./useLocalStorage"; |
| 31 | 31 | import { normalizeCustomModelSlugs } from "~/modelSelection"; |
| 32 | 32 | import { Predicate, Schema, Struct } from "effect"; |
| @@ -67,9 +67,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): { · 34 unmodified lines | ||
| 67 | 67 | * only re-render when the slice they care about changes. |
| 68 | 68 | */ |
| 69 | 69 | |
| 70 | −export function useSettings<T extends UnifiedSettings = UnifiedSettings>( | |
| 71 | − selector?: (s: UnifiedSettings) => T, | |
| 72 | −): T { | |
| 70 | +export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { | |
| 73 | 71 | const serverSettings = useServerSettings(); |
| 74 | 72 | const [clientSettings] = useLocalStorage( |
| 75 | 73 | CLIENT_SETTINGS_STORAGE_KEY, |
| @@ -111,7 +109,7 @@ export function useUpdateSettings() { · 35 unmodified lines | ||
| 111 | 109 | applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); |
| 112 | 110 | } |
| 113 | 111 | // Fire-and-forget RPC — push will reconcile on success |
| 114 | − void ensureNativeApi().server.updateSettings(serverPatch); | |
| 112 | + void ensureLocalApi().server.updateSettings(serverPatch); | |
| 115 | 113 | } |
| 116 | 114 | |
| 117 | 115 | if (Object.keys(clientPatch).length > 0) { |
| @@ -239,7 +237,7 @@ export function migrateLocalSettingsToServer(): void { · 121 unmodified lines | ||
| 239 | 237 | // Migrate server-relevant keys via RPC |
| 240 | 238 | const serverPatch = buildLegacyServerSettingsMigrationPatch(old); |
| 241 | 239 | if (Object.keys(serverPatch).length > 0) { |
| 242 | − const api = ensureNativeApi(); | |
| 240 | + const api = ensureLocalApi(); | |
| 243 | 241 | void api.server.updateSettings(serverPatch); |
| 244 | 242 | } |
| 245 | 243 | |
| @@ -1,42 +1,63 @@ | ||
| 1 | −import { ThreadId } from "@t3tools/contracts"; | |
| 2 | −import { useMutation, useQueryClient } from "@tanstack/react-query"; | |
| 3 | −import { useNavigate, useParams } from "@tanstack/react-router"; | |
| 1 | +import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | |
| 2 | +import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | |
| 3 | +import { useQueryClient } from "@tanstack/react-query"; | |
| 4 | +import { useRouter } from "@tanstack/react-router"; | |
| 4 | 5 | import { useCallback } from "react"; |
| 5 | 6 | |
| 6 | 7 | import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; |
| 7 | 8 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 8 | 9 | import { useHandleNewThread } from "./useHandleNewThread"; |
| 9 | −import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | |
| 10 | +import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; | |
| 11 | +import { invalidateGitQueries } from "../lib/gitReactQuery"; | |
| 10 | 12 | import { newCommandId } from "../lib/utils"; |
| 11 | −import { readNativeApi } from "../nativeApi"; | |
| 12 | −import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store"; | |
| 13 | +import { readLocalApi } from "../localApi"; | |
| 14 | +import { | |
| 15 | + selectProjectByRef, | |
| 16 | + selectThreadByRef, | |
| 17 | + selectThreadsForEnvironment, | |
| 18 | + useStore, | |
| 19 | +} from "../store"; | |
| 13 | 20 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 21 | +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | |
| 14 | 22 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 15 | 23 | import { toastManager } from "../components/ui/toast"; |
| 16 | 24 | import { useSettings } from "./useSettings"; |
| 17 | 25 | |
| 18 | 26 | export function useThreadActions() { |
| 19 | − const appSettings = useSettings(); | |
| 27 | + const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); | |
| 28 | + const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); | |
| 20 | 29 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 21 | 30 | const clearProjectDraftThreadById = useComposerDraftStore( |
| 22 | 31 | (store) => store.clearProjectDraftThreadById, |
| 23 | 32 | ); |
| 24 | 33 | const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); |
| 25 | − const routeThreadId = useParams({ | |
| 26 | − strict: false, | |
| 27 | − select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | |
| 28 | − }); | |
| 29 | − const navigate = useNavigate(); | |
| 34 | + const router = useRouter(); | |
| 30 | 35 | const { handleNewThread } = useHandleNewThread(); |
| 31 | 36 | const queryClient = useQueryClient(); |
| 32 | − const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); | |
| 37 | + | |
| 38 | + const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { | |
| 39 | + const state = useStore.getState(); | |
| 40 | + const thread = selectThreadByRef(state, target); | |
| 41 | + if (!thread) { | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + return { | |
| 45 | + thread, | |
| 46 | + threadRef: target, | |
| 47 | + }; | |
| 48 | + }, []); | |
| 49 | + const getCurrentRouteThreadRef = useCallback(() => { | |
| 50 | + const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; | |
| 51 | + return resolveThreadRouteRef(currentRouteParams); | |
| 52 | + }, [router]); | |
| 33 | 53 | |
| 34 | 54 | const archiveThread = useCallback( |
| 35 | − async (threadId: ThreadId) => { | |
| 36 | − const api = readNativeApi(); | |
| 55 | + async (target: ScopedThreadRef) => { | |
| 56 | + const api = readEnvironmentApi(target.environmentId); | |
| 37 | 57 | if (!api) return; |
| 38 | − const thread = selectThreadById(threadId)(useStore.getState()); | |
| 39 | − if (!thread) return; | |
| 58 | + const resolved = resolveThreadTarget(target); | |
| 59 | + if (!resolved) return; | |
| 60 | + const { thread, threadRef } = resolved; | |
| 40 | 61 | if (thread.session?.status === "running" && thread.session.activeTurnId != null) { |
| 41 | 62 | throw new Error("Cannot archive a running thread."); |
| 42 | 63 | } |
| @@ -44,48 +65,69 @@ export function useThreadActions() { · 1 unmodified line | ||
| 44 | 65 | await api.orchestration.dispatchCommand({ |
| 45 | 66 | type: "thread.archive", |
| 46 | 67 | commandId: newCommandId(), |
| 47 | − threadId, | |
| 68 | + threadId: threadRef.threadId, | |
| 48 | 69 | }); |
| 70 | + const currentRouteThreadRef = getCurrentRouteThreadRef(); | |
| 49 | 71 | |
| 50 | − if (routeThreadId === threadId) { | |
| 51 | − await handleNewThread(thread.projectId); | |
| 72 | + if ( | |
| 73 | + currentRouteThreadRef?.threadId === threadRef.threadId && | |
| 74 | + currentRouteThreadRef.environmentId === threadRef.environmentId | |
| 75 | + ) { | |
| 76 | + await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId)); | |
| 52 | 77 | } |
| 53 | 78 | }, |
| 54 | − [handleNewThread, routeThreadId], | |
| 79 | + [getCurrentRouteThreadRef, handleNewThread, resolveThreadTarget], | |
| 55 | 80 | ); |
| 56 | 81 | |
| 57 | − const unarchiveThread = useCallback(async (threadId: ThreadId) => { | |
| 58 | − const api = readNativeApi(); | |
| 82 | + const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { | |
| 83 | + const api = readEnvironmentApi(target.environmentId); | |
| 59 | 84 | if (!api) return; |
| 60 | 85 | await api.orchestration.dispatchCommand({ |
| 61 | 86 | type: "thread.unarchive", |
| 62 | 87 | commandId: newCommandId(), |
| 63 | − threadId, | |
| 88 | + threadId: target.threadId, | |
| 64 | 89 | }); |
| 65 | 90 | }, []); |
| 66 | 91 | |
| 67 | 92 | const deleteThread = useCallback( |
| 68 | − async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => { | |
| 69 | − const api = readNativeApi(); | |
| 93 | + async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => { | |
| 94 | + const api = readEnvironmentApi(target.environmentId); | |
| 70 | 95 | if (!api) return; |
| 96 | + const resolved = resolveThreadTarget(target); | |
| 97 | + if (!resolved) return; | |
| 98 | + const { thread, threadRef } = resolved; | |
| 71 | 99 | const state = useStore.getState(); |
| 72 | − const threads = selectThreads(state); | |
| 73 | − const thread = selectThreadById(threadId)(state); | |
| 74 | − if (!thread) return; | |
| 75 | − const threadProject = selectProjectById(thread.projectId)(state); | |
| 76 | − const deletedIds = opts.deletedThreadIds; | |
| 100 | + const threads = selectThreadsForEnvironment(state, threadRef.environmentId); | |
| 101 | + const threadProject = selectProjectByRef(state, { | |
| 102 | + environmentId: threadRef.environmentId, | |
| 103 | + projectId: thread.projectId, | |
| 104 | + }); | |
| 105 | + const deletedIds = | |
| 106 | + opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0 | |
| 107 | + ? new Set<ThreadId>( | |
| 108 | + [...opts.deletedThreadKeys].flatMap((threadKey) => { | |
| 109 | + const ref = parseScopedThreadKey(threadKey); | |
| 110 | + return ref && ref.environmentId === threadRef.environmentId ? [ref.threadId] : []; | |
| 111 | + }), | |
| 112 | + ) | |
| 113 | + : undefined; | |
| 77 | 114 | const survivingThreads = |
| 78 | 115 | deletedIds && deletedIds.size > 0 |
| 79 | − ? threads.filter((entry) => entry.id === threadId || !deletedIds.has(entry.id)) | |
| 116 | + ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) | |
| 80 | 117 | : threads; |
| 81 | − const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId); | |
| 118 | + const orphanedWorktreePath = getOrphanedWorktreePathForThread( | |
| 119 | + survivingThreads, | |
| 120 | + threadRef.threadId, | |
| 121 | + ); | |
| 82 | 122 | const displayWorktreePath = orphanedWorktreePath |
| 83 | 123 | ? formatWorktreePathForDisplay(orphanedWorktreePath) |
| 84 | 124 | : null; |
| 85 | 125 | const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; |
| 126 | + const localApi = readLocalApi(); | |
| 86 | 127 | const shouldDeleteWorktree = |
| 87 | 128 | canDeleteWorktree && |
| 88 | − (await api.dialogs.confirm( | |
| 129 | + localApi && | |
| 130 | + (await localApi.dialogs.confirm( | |
| 89 | 131 | [ |
| 90 | 132 | "This thread is the only one linked to this worktree:", |
| 91 | 133 | displayWorktreePath ?? orphanedWorktreePath, |
| @@ -99,44 +141,60 @@ export function useThreadActions() { · 7 unmodified lines | ||
| 99 | 141 | .dispatchCommand({ |
| 100 | 142 | type: "thread.session.stop", |
| 101 | 143 | commandId: newCommandId(), |
| 102 | − threadId, | |
| 144 | + threadId: threadRef.threadId, | |
| 103 | 145 | createdAt: new Date().toISOString(), |
| 104 | 146 | }) |
| 105 | 147 | .catch(() => undefined); |
| 106 | 148 | } |
| 107 | 149 | |
| 108 | 150 | try { |
| 109 | − await api.terminal.close({ threadId, deleteHistory: true }); | |
| 151 | + await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true }); | |
| 110 | 152 | } catch { |
| 111 | 153 | // Terminal may already be closed. |
| 112 | 154 | } |
| 113 | 155 | |
| 114 | − const deletedThreadIds = opts.deletedThreadIds ?? new Set<ThreadId>(); | |
| 115 | − const shouldNavigateToFallback = routeThreadId === threadId; | |
| 156 | + const deletedThreadIds = deletedIds ?? new Set<ThreadId>(); | |
| 157 | + const currentRouteThreadRef = getCurrentRouteThreadRef(); | |
| 158 | + const shouldNavigateToFallback = | |
| 159 | + currentRouteThreadRef?.threadId === threadRef.threadId && | |
| 160 | + currentRouteThreadRef.environmentId === threadRef.environmentId; | |
| 116 | 161 | const fallbackThreadId = getFallbackThreadIdAfterDelete({ |
| 117 | 162 | threads, |
| 118 | − deletedThreadId: threadId, | |
| 163 | + deletedThreadId: threadRef.threadId, | |
| 119 | 164 | deletedThreadIds, |
| 120 | − sortOrder: appSettings.sidebarThreadSortOrder, | |
| 165 | + sortOrder: sidebarThreadSortOrder, | |
| 121 | 166 | }); |
| 122 | 167 | await api.orchestration.dispatchCommand({ |
| 123 | 168 | type: "thread.delete", |
| 124 | 169 | commandId: newCommandId(), |
| 125 | − threadId, | |
| 170 | + threadId: threadRef.threadId, | |
| 126 | 171 | }); |
| 127 | − clearComposerDraftForThread(threadId); | |
| 128 | − clearProjectDraftThreadById(thread.projectId, thread.id); | |
| 129 | − clearTerminalState(threadId); | |
| 172 | + clearComposerDraftForThread(threadRef); | |
| 173 | + clearProjectDraftThreadById( | |
| 174 | + scopeProjectRef(threadRef.environmentId, thread.projectId), | |
| 175 | + threadRef, | |
| 176 | + ); | |
| 177 | + clearTerminalState(threadRef); | |
| 130 | 178 | |
| 131 | 179 | if (shouldNavigateToFallback) { |
| 132 | 180 | if (fallbackThreadId) { |
| 133 | − await navigate({ | |
| 134 | − to: "/$threadId", | |
| 135 | − params: { threadId: fallbackThreadId }, | |
| 136 | − replace: true, | |
| 137 | − }); | |
| 181 | + const fallbackThread = selectThreadByRef( | |
| 182 | + useStore.getState(), | |
| 183 | + scopeThreadRef(threadRef.environmentId, fallbackThreadId), | |
| 184 | + ); | |
| 185 | + if (fallbackThread) { | |
| 186 | + await router.navigate({ | |
| 187 | + to: "/$environmentId/$threadId", | |
| 188 | + params: buildThreadRouteParams( | |
| 189 | + scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), | |
| 190 | + ), | |
| 191 | + replace: true, | |
| 192 | + }); | |
| 193 | + } else { | |
| 194 | + await router.navigate({ to: "/", replace: true }); | |
| 195 | + } | |
| 138 | 196 | } else { |
| 139 | − await navigate({ to: "/", replace: true }); | |
| 197 | + await router.navigate({ to: "/", replace: true }); | |
| 140 | 198 | } |
| 141 | 199 | } |
| 142 | 200 | |
| @@ -145,15 +203,18 @@ export function useThreadActions() { · 2 unmodified lines | ||
| 145 | 203 | } |
| 146 | 204 | |
| 147 | 205 | try { |
| 148 | − await removeWorktreeMutation.mutateAsync({ | |
| 206 | + await ensureEnvironmentApi(threadRef.environmentId).git.removeWorktree({ | |
| 149 | 207 | cwd: threadProject.cwd, |
| 150 | 208 | path: orphanedWorktreePath, |
| 151 | 209 | force: true, |
| 152 | 210 | }); |
| 211 | + await invalidateGitQueries(queryClient, { | |
| 212 | + environmentId: threadRef.environmentId, | |
| 213 | + }); | |
| 153 | 214 | } catch (error) { |
| 154 | 215 | const message = error instanceof Error ? error.message : "Unknown error removing worktree."; |
| 155 | 216 | console.error("Failed to remove orphaned worktree after thread deletion", { |
| 156 | − threadId, | |
| 217 | + threadId: threadRef.threadId, | |
| 157 | 218 | projectCwd: threadProject.cwd, |
| 158 | 219 | worktreePath: orphanedWorktreePath, |
| 159 | 220 | error, |
| @@ -169,22 +230,25 @@ export function useThreadActions() { · 9 unmodified lines | ||
| 169 | 230 | clearComposerDraftForThread, |
| 170 | 231 | clearProjectDraftThreadById, |
| 171 | 232 | clearTerminalState, |
| 172 | − appSettings.sidebarThreadSortOrder, | |
| 173 | − navigate, | |
| 174 | − removeWorktreeMutation, | |
| 175 | − routeThreadId, | |
| 233 | + getCurrentRouteThreadRef, | |
| 234 | + router, | |
| 235 | + queryClient, | |
| 236 | + resolveThreadTarget, | |
| 237 | + sidebarThreadSortOrder, | |
| 176 | 238 | ], |
| 177 | 239 | ); |
| 178 | 240 | |
| 179 | 241 | const confirmAndDeleteThread = useCallback( |
| 180 | − async (threadId: ThreadId) => { | |
| 181 | − const api = readNativeApi(); | |
| 242 | + async (target: ScopedThreadRef) => { | |
| 243 | + const api = readEnvironmentApi(target.environmentId); | |
| 182 | 244 | if (!api) return; |
| 183 | − const thread = selectThreadById(threadId)(useStore.getState()); | |
| 184 | − if (!thread) return; | |
| 245 | + const localApi = readLocalApi(); | |
| 246 | + const resolved = resolveThreadTarget(target); | |
| 247 | + if (!resolved) return; | |
| 248 | + const { thread } = resolved; | |
| 185 | 249 | |
| 186 | − if (appSettings.confirmThreadDelete) { | |
| 187 | − const confirmed = await api.dialogs.confirm( | |
| 250 | + if (confirmThreadDelete && localApi) { | |
| 251 | + const confirmed = await localApi.dialogs.confirm( | |
| 188 | 252 | [ |
| 189 | 253 | `Delete thread "${thread.title}"?`, |
| 190 | 254 | "This permanently clears conversation history for this thread.", |
| @@ -195,9 +259,9 @@ export function useThreadActions() { · 4 unmodified lines | ||
| 195 | 259 | } |
| 196 | 260 | } |
| 197 | 261 | |
| 198 | − await deleteThread(threadId); | |
| 262 | + await deleteThread(target); | |
| 199 | 263 | }, |
| 200 | − [appSettings.confirmThreadDelete, deleteThread], | |
| 264 | + [confirmThreadDelete, deleteThread, resolveThreadTarget], | |
| 201 | 265 | ); |
| 202 | 266 | |
| 203 | 267 | return { |
| @@ -1,16 +1,17 @@ | ||
| 1 | 1 | import { QueryClient } from "@tanstack/react-query"; |
| 2 | 2 | import { describe, expect, it, vi } from "vitest"; |
| 3 | 3 | |
| 4 | −vi.mock("../nativeApi", () => ({ | |
| 5 | − ensureNativeApi: vi.fn(), | |
| 4 | +vi.mock("../environmentApi", () => ({ | |
| 5 | + ensureEnvironmentApi: vi.fn(), | |
| 6 | 6 | })); |
| 7 | 7 | |
| 8 | 8 | vi.mock("../wsRpcClient", () => ({ |
| 9 | 9 | getWsRpcClient: vi.fn(), |
| 10 | + getWsRpcClientForEnvironment: vi.fn(), | |
| 10 | 11 | })); |
| 11 | 12 | |
| 12 | 13 | import type { InfiniteData } from "@tanstack/react-query"; |
| 13 | −import type { GitListBranchesResult } from "@t3tools/contracts"; | |
| 14 | +import { EnvironmentId, type GitListBranchesResult } from "@t3tools/contracts"; | |
| 14 | 15 | |
| 15 | 16 | import { |
| 16 | 17 | gitBranchSearchInfiniteQueryOptions, |
| @@ -33,21 +34,25 @@ const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = { · 16 unmodified lines | ||
| 33 | 34 | pages: [BRANCH_QUERY_RESULT], |
| 34 | 35 | pageParams: [0], |
| 35 | 36 | }; |
| 37 | +const ENVIRONMENT_A = EnvironmentId.makeUnsafe("environment-a"); | |
| 38 | +const ENVIRONMENT_B = EnvironmentId.makeUnsafe("environment-b"); | |
| 36 | 39 | |
| 37 | 40 | describe("gitMutationKeys", () => { |
| 38 | 41 | it("scopes stacked action keys by cwd", () => { |
| 39 | − expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( | |
| 40 | − gitMutationKeys.runStackedAction("/repo/b"), | |
| 42 | + expect(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")).not.toEqual( | |
| 43 | + gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/b"), | |
| 41 | 44 | ); |
| 42 | 45 | }); |
| 43 | 46 | |
| 44 | 47 | it("scopes pull keys by cwd", () => { |
| 45 | − expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); | |
| 48 | + expect(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")).not.toEqual( | |
| 49 | + gitMutationKeys.pull(ENVIRONMENT_A, "/repo/b"), | |
| 50 | + ); | |
| 46 | 51 | }); |
| 47 | 52 | |
| 48 | 53 | it("scopes pull request thread preparation keys by cwd", () => { |
| 49 | − expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual( | |
| 50 | − gitMutationKeys.preparePullRequestThread("/repo/b"), | |
| 54 | + expect(gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a")).not.toEqual( | |
| 55 | + gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/b"), | |
| 51 | 56 | ); |
| 52 | 57 | }); |
| 53 | 58 | }); |
| @@ -57,23 +62,31 @@ describe("git mutation options", () => { · 3 unmodified lines | ||
| 57 | 62 | |
| 58 | 63 | it("attaches cwd-scoped mutation key for runStackedAction", () => { |
| 59 | 64 | const options = gitRunStackedActionMutationOptions({ |
| 65 | + environmentId: ENVIRONMENT_A, | |
| 60 | 66 | cwd: "/repo/a", |
| 61 | 67 | queryClient, |
| 62 | 68 | }); |
| 63 | − expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); | |
| 69 | + expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")); | |
| 64 | 70 | }); |
| 65 | 71 | |
| 66 | 72 | it("attaches cwd-scoped mutation key for pull", () => { |
| 67 | − const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); | |
| 68 | − expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); | |
| 73 | + const options = gitPullMutationOptions({ | |
| 74 | + environmentId: ENVIRONMENT_A, | |
| 75 | + cwd: "/repo/a", | |
| 76 | + queryClient, | |
| 77 | + }); | |
| 78 | + expect(options.mutationKey).toEqual(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")); | |
| 69 | 79 | }); |
| 70 | 80 | |
| 71 | 81 | it("attaches cwd-scoped mutation key for preparePullRequestThread", () => { |
| 72 | 82 | const options = gitPreparePullRequestThreadMutationOptions({ |
| 83 | + environmentId: ENVIRONMENT_A, | |
| 73 | 84 | cwd: "/repo/a", |
| 74 | 85 | queryClient, |
| 75 | 86 | }); |
| 76 | − expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a")); | |
| 87 | + expect(options.mutationKey).toEqual( | |
| 88 | + gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a"), | |
| 89 | + ); | |
| 77 | 90 | }); |
| 78 | 91 | }); |
| 79 | 92 | |
| @@ -83,6 +96,7 @@ describe("invalidateGitQueries", () => { · 3 unmodified lines | ||
| 83 | 96 | |
| 84 | 97 | queryClient.setQueryData( |
| 85 | 98 | gitBranchSearchInfiniteQueryOptions({ |
| 99 | + environmentId: ENVIRONMENT_A, | |
| 86 | 100 | cwd: "/repo/a", |
| 87 | 101 | query: "feature", |
| 88 | 102 | }).queryKey, |
| @@ -90,17 +104,19 @@ describe("invalidateGitQueries", () => { · 1 unmodified line | ||
| 90 | 104 | ); |
| 91 | 105 | queryClient.setQueryData( |
| 92 | 106 | gitBranchSearchInfiniteQueryOptions({ |
| 107 | + environmentId: ENVIRONMENT_B, | |
| 93 | 108 | cwd: "/repo/b", |
| 94 | 109 | query: "feature", |
| 95 | 110 | }).queryKey, |
| 96 | 111 | BRANCH_SEARCH_RESULT, |
| 97 | 112 | ); |
| 98 | 113 | |
| 99 | − await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); | |
| 114 | + await invalidateGitQueries(queryClient, { environmentId: ENVIRONMENT_A, cwd: "/repo/a" }); | |
| 100 | 115 | |
| 101 | 116 | expect( |
| 102 | 117 | queryClient.getQueryState( |
| 103 | 118 | gitBranchSearchInfiniteQueryOptions({ |
| 119 | + environmentId: ENVIRONMENT_A, | |
| 104 | 120 | cwd: "/repo/a", |
| 105 | 121 | query: "feature", |
| 106 | 122 | }).queryKey, |
| @@ -109,6 +125,7 @@ describe("invalidateGitQueries", () => { · 2 unmodified lines | ||
| 109 | 125 | expect( |
| 110 | 126 | queryClient.getQueryState( |
| 111 | 127 | gitBranchSearchInfiniteQueryOptions({ |
| 128 | + environmentId: ENVIRONMENT_B, | |
| 112 | 129 | cwd: "/repo/b", |
| 113 | 130 | query: "feature", |
| 114 | 131 | }).queryKey, |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + type EnvironmentId, | |
| 2 | 3 | type GitActionProgressEvent, |
| 3 | 4 | type GitStackedAction, |
| 4 | 5 | type ThreadId, |
| @@ -9,8 +10,8 @@ import { · 4 unmodified lines | ||
| 9 | 10 | queryOptions, |
| 10 | 11 | type QueryClient, |
| 11 | 12 | } from "@tanstack/react-query"; |
| 12 | −import { ensureNativeApi } from "../nativeApi"; | |
| 13 | −import { getWsRpcClient } from "../wsRpcClient"; | |
| 13 | +import { ensureEnvironmentApi } from "../environmentApi"; | |
| 14 | +import { getWsRpcClientForEnvironment } from "../wsRpcClient"; | |
| 14 | 15 | |
| 15 | 16 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; |
| 16 | 17 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; |
| @@ -18,38 +19,52 @@ const GIT_BRANCHES_PAGE_SIZE = 100; · 1 unmodified line | ||
| 18 | 19 | |
| 19 | 20 | export const gitQueryKeys = { |
| 20 | 21 | all: ["git"] as const, |
| 21 | − branches: (cwd: string | null) => ["git", "branches", cwd] as const, | |
| 22 | − branchSearch: (cwd: string | null, query: string) => | |
| 23 | − ["git", "branches", cwd, "search", query] as const, | |
| 22 | + branches: (environmentId: EnvironmentId | null, cwd: string | null) => | |
| 23 | + ["git", "branches", environmentId ?? null, cwd] as const, | |
| 24 | + branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) => | |
| 25 | + ["git", "branches", environmentId ?? null, cwd, "search", query] as const, | |
| 24 | 26 | }; |
| 25 | 27 | |
| 26 | 28 | export const gitMutationKeys = { |
| 27 | − init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const, | |
| 28 | − checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const, | |
| 29 | − runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, | |
| 30 | − pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, | |
| 31 | − preparePullRequestThread: (cwd: string | null) => | |
| 32 | − ["git", "mutation", "prepare-pull-request-thread", cwd] as const, | |
| 29 | + init: (environmentId: EnvironmentId | null, cwd: string | null) => | |
| 30 | + ["git", "mutation", "init", environmentId ?? null, cwd] as const, | |
| 31 | + checkout: (environmentId: EnvironmentId | null, cwd: string | null) => | |
| 32 | + ["git", "mutation", "checkout", environmentId ?? null, cwd] as const, | |
| 33 | + runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) => | |
| 34 | + ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const, | |
| 35 | + pull: (environmentId: EnvironmentId | null, cwd: string | null) => | |
| 36 | + ["git", "mutation", "pull", environmentId ?? null, cwd] as const, | |
| 37 | + preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) => | |
| 38 | + ["git", "mutation", "prepare-pull-request-thread", environmentId ?? null, cwd] as const, | |
| 33 | 39 | }; |
| 34 | 40 | |
| 35 | −export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) { | |
| 41 | +export function invalidateGitQueries( | |
| 42 | + queryClient: QueryClient, | |
| 43 | + input?: { environmentId?: EnvironmentId | null; cwd?: string | null }, | |
| 44 | +) { | |
| 45 | + const environmentId = input?.environmentId ?? null; | |
| 36 | 46 | const cwd = input?.cwd ?? null; |
| 37 | 47 | if (cwd !== null) { |
| 38 | − return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); | |
| 48 | + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); | |
| 39 | 49 | } |
| 40 | 50 | |
| 41 | 51 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); |
| 42 | 52 | } |
| 43 | 53 | |
| 44 | −function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null) { | |
| 54 | +function invalidateGitBranchQueries( | |
| 55 | + queryClient: QueryClient, | |
| 56 | + environmentId: EnvironmentId | null, | |
| 57 | + cwd: string | null, | |
| 58 | +) { | |
| 45 | 59 | if (cwd === null) { |
| 46 | 60 | return Promise.resolve(); |
| 47 | 61 | } |
| 48 | 62 | |
| 49 | − return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); | |
| 63 | + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); | |
| 50 | 64 | } |
| 51 | 65 | |
| 52 | 66 | export function gitBranchSearchInfiniteQueryOptions(input: { |
| 67 | + environmentId: EnvironmentId | null; | |
| 53 | 68 | cwd: string | null; |
| 54 | 69 | query: string; |
| 55 | 70 | enabled?: boolean; |
| @@ -57,11 +72,12 @@ export function gitBranchSearchInfiniteQueryOptions(input: { · 1 unmodified line | ||
| 57 | 72 | const normalizedQuery = input.query.trim(); |
| 58 | 73 | |
| 59 | 74 | return infiniteQueryOptions({ |
| 60 | − queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery), | |
| 75 | + queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery), | |
| 61 | 76 | initialPageParam: 0, |
| 62 | 77 | queryFn: async ({ pageParam }) => { |
| 63 | − const api = ensureNativeApi(); | |
| 64 | 78 | if (!input.cwd) throw new Error("Git branches are unavailable."); |
| 79 | + if (!input.environmentId) throw new Error("Git branches are unavailable."); | |
| 80 | + const api = ensureEnvironmentApi(input.environmentId); | |
| 65 | 81 | return api.git.listBranches({ |
| 66 | 82 | cwd: input.cwd, |
| 67 | 83 | ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), |
| @@ -79,62 +95,75 @@ export function gitBranchSearchInfiniteQueryOptions(input: { · 11 unmodified lines | ||
| 79 | 95 | } |
| 80 | 96 | |
| 81 | 97 | export function gitResolvePullRequestQueryOptions(input: { |
| 98 | + environmentId: EnvironmentId | null; | |
| 82 | 99 | cwd: string | null; |
| 83 | 100 | reference: string | null; |
| 84 | 101 | }) { |
| 85 | 102 | return queryOptions({ |
| 86 | − queryKey: ["git", "pull-request", input.cwd, input.reference] as const, | |
| 103 | + queryKey: [ | |
| 104 | + "git", | |
| 105 | + "pull-request", | |
| 106 | + input.environmentId ?? null, | |
| 107 | + input.cwd, | |
| 108 | + input.reference, | |
| 109 | + ] as const, | |
| 87 | 110 | queryFn: async () => { |
| 88 | − const api = ensureNativeApi(); | |
| 89 | − if (!input.cwd || !input.reference) { | |
| 111 | + if (!input.cwd || !input.reference || !input.environmentId) { | |
| 90 | 112 | throw new Error("Pull request lookup is unavailable."); |
| 91 | 113 | } |
| 114 | + const api = ensureEnvironmentApi(input.environmentId); | |
| 92 | 115 | return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference }); |
| 93 | 116 | }, |
| 94 | − enabled: input.cwd !== null && input.reference !== null, | |
| 117 | + enabled: input.environmentId !== null && input.cwd !== null && input.reference !== null, | |
| 95 | 118 | staleTime: 30_000, |
| 96 | 119 | refetchOnWindowFocus: false, |
| 97 | 120 | refetchOnReconnect: false, |
| 98 | 121 | }); |
| 99 | 122 | } |
| 100 | 123 | |
| 101 | −export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { | |
| 124 | +export function gitInitMutationOptions(input: { | |
| 125 | + environmentId: EnvironmentId | null; | |
| 126 | + cwd: string | null; | |
| 127 | + queryClient: QueryClient; | |
| 128 | +}) { | |
| 102 | 129 | return mutationOptions({ |
| 103 | − mutationKey: gitMutationKeys.init(input.cwd), | |
| 130 | + mutationKey: gitMutationKeys.init(input.environmentId, input.cwd), | |
| 104 | 131 | mutationFn: async () => { |
| 105 | − const api = ensureNativeApi(); | |
| 106 | − if (!input.cwd) throw new Error("Git init is unavailable."); | |
| 132 | + if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable."); | |
| 133 | + const api = ensureEnvironmentApi(input.environmentId); | |
| 107 | 134 | return api.git.init({ cwd: input.cwd }); |
| 108 | 135 | }, |
| 109 | 136 | onSettled: async () => { |
| 110 | − await invalidateGitBranchQueries(input.queryClient, input.cwd); | |
| 137 | + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | |
| 111 | 138 | }, |
| 112 | 139 | }); |
| 113 | 140 | } |
| 114 | 141 | |
| 115 | 142 | export function gitCheckoutMutationOptions(input: { |
| 143 | + environmentId: EnvironmentId | null; | |
| 116 | 144 | cwd: string | null; |
| 117 | 145 | queryClient: QueryClient; |
| 118 | 146 | }) { |
| 119 | 147 | return mutationOptions({ |
| 120 | − mutationKey: gitMutationKeys.checkout(input.cwd), | |
| 148 | + mutationKey: gitMutationKeys.checkout(input.environmentId, input.cwd), | |
| 121 | 149 | mutationFn: async (branch: string) => { |
| 122 | − const api = ensureNativeApi(); | |
| 123 | − if (!input.cwd) throw new Error("Git checkout is unavailable."); | |
| 150 | + if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable."); | |
| 151 | + const api = ensureEnvironmentApi(input.environmentId); | |
| 124 | 152 | return api.git.checkout({ cwd: input.cwd, branch }); |
| 125 | 153 | }, |
| 126 | 154 | onSettled: async () => { |
| 127 | − await invalidateGitBranchQueries(input.queryClient, input.cwd); | |
| 155 | + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | |
| 128 | 156 | }, |
| 129 | 157 | }); |
| 130 | 158 | } |
| 131 | 159 | |
| 132 | 160 | export function gitRunStackedActionMutationOptions(input: { |
| 161 | + environmentId: EnvironmentId | null; | |
| 133 | 162 | cwd: string | null; |
| 134 | 163 | queryClient: QueryClient; |
| 135 | 164 | }) { |
| 136 | 165 | return mutationOptions({ |
| 137 | − mutationKey: gitMutationKeys.runStackedAction(input.cwd), | |
| 166 | + mutationKey: gitMutationKeys.runStackedAction(input.environmentId, input.cwd), | |
| 138 | 167 | mutationFn: async ({ |
| 139 | 168 | actionId, |
| 140 | 169 | action, |
| @@ -150,8 +179,8 @@ export function gitRunStackedActionMutationOptions(input: { · 9 unmodified lines | ||
| 150 | 179 | filePaths?: string[]; |
| 151 | 180 | onProgress?: (event: GitActionProgressEvent) => void; |
| 152 | 181 | }) => { |
| 153 | − if (!input.cwd) throw new Error("Git action is unavailable."); | |
| 154 | − return getWsRpcClient().git.runStackedAction( | |
| 182 | + if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable."); | |
| 183 | + return getWsRpcClientForEnvironment(input.environmentId).git.runStackedAction( | |
| 155 | 184 | { |
| 156 | 185 | action, |
| 157 | 186 | actionId, |
| @@ -164,62 +193,85 @@ export function gitRunStackedActionMutationOptions(input: { · 6 unmodified lines | ||
| 164 | 193 | ); |
| 165 | 194 | }, |
| 166 | 195 | onSuccess: async () => { |
| 167 | − await invalidateGitBranchQueries(input.queryClient, input.cwd); | |
| 196 | + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | |
| 168 | 197 | }, |
| 169 | 198 | }); |
| 170 | 199 | } |
| 171 | 200 | |
| 172 | −export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { | |
| 201 | +export function gitPullMutationOptions(input: { | |
| 202 | + environmentId: EnvironmentId | null; | |
| 203 | + cwd: string | null; | |
| 204 | + queryClient: QueryClient; | |
| 205 | +}) { | |
| 173 | 206 | return mutationOptions({ |
| 174 | − mutationKey: gitMutationKeys.pull(input.cwd), | |
| 207 | + mutationKey: gitMutationKeys.pull(input.environmentId, input.cwd), | |
| 175 | 208 | mutationFn: async () => { |
| 176 | − const api = ensureNativeApi(); | |
| 177 | − if (!input.cwd) throw new Error("Git pull is unavailable."); | |
| 209 | + if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable."); | |
| 210 | + const api = ensureEnvironmentApi(input.environmentId); | |
| 178 | 211 | return api.git.pull({ cwd: input.cwd }); |
| 179 | 212 | }, |
| 180 | 213 | onSuccess: async () => { |
| 181 | − await invalidateGitBranchQueries(input.queryClient, input.cwd); | |
| 214 | + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | |
| 182 | 215 | }, |
| 183 | 216 | }); |
| 184 | 217 | } |
| 185 | 218 | |
| 186 | −export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { | |
| 219 | +export function gitCreateWorktreeMutationOptions(input: { | |
| 220 | + environmentId: EnvironmentId | null; | |
| 221 | + queryClient: QueryClient; | |
| 222 | +}) { | |
| 187 | 223 | return mutationOptions({ |
| 188 | − mutationKey: ["git", "mutation", "create-worktree"] as const, | |
| 224 | + mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const, | |
| 189 | 225 | mutationFn: ( |
| 190 | − args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["createWorktree"]>[0], | |
| 191 | − ) => ensureNativeApi().git.createWorktree(args), | |
| 226 | + args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["createWorktree"]>[0], | |
| 227 | + ) => { | |
| 228 | + if (!input.environmentId) { | |
| 229 | + throw new Error("Worktree creation is unavailable."); | |
| 230 | + } | |
| 231 | + return ensureEnvironmentApi(input.environmentId).git.createWorktree(args); | |
| 232 | + }, | |
| 192 | 233 | onSuccess: async () => { |
| 193 | − await invalidateGitQueries(input.queryClient); | |
| 234 | + await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); | |
| 194 | 235 | }, |
| 195 | 236 | }); |
| 196 | 237 | } |
| 197 | 238 | |
| 198 | −export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { | |
| 239 | +export function gitRemoveWorktreeMutationOptions(input: { | |
| 240 | + environmentId: EnvironmentId | null; | |
| 241 | + queryClient: QueryClient; | |
| 242 | +}) { | |
| 199 | 243 | return mutationOptions({ |
| 200 | − mutationKey: ["git", "mutation", "remove-worktree"] as const, | |
| 244 | + mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const, | |
| 201 | 245 | mutationFn: ( |
| 202 | − args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["removeWorktree"]>[0], | |
| 203 | − ) => ensureNativeApi().git.removeWorktree(args), | |
| 246 | + args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["removeWorktree"]>[0], | |
| 247 | + ) => { | |
| 248 | + if (!input.environmentId) { | |
| 249 | + throw new Error("Worktree removal is unavailable."); | |
| 250 | + } | |
| 251 | + return ensureEnvironmentApi(input.environmentId).git.removeWorktree(args); | |
| 252 | + }, | |
| 204 | 253 | onSuccess: async () => { |
| 205 | − await invalidateGitQueries(input.queryClient); | |
| 254 | + await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); | |
| 206 | 255 | }, |
| 207 | 256 | }); |
| 208 | 257 | } |
| 209 | 258 | |
| 210 | 259 | export function gitPreparePullRequestThreadMutationOptions(input: { |
| 260 | + environmentId: EnvironmentId | null; | |
| 211 | 261 | cwd: string | null; |
| 212 | 262 | queryClient: QueryClient; |
| 213 | 263 | }) { |
| 214 | 264 | return mutationOptions({ |
| 215 | − mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), | |
| 265 | + mutationKey: gitMutationKeys.preparePullRequestThread(input.environmentId, input.cwd), | |
| 216 | 266 | mutationFn: async (args: { |
| 217 | 267 | reference: string; |
| 218 | 268 | mode: "local" | "worktree"; |
| 219 | 269 | threadId?: ThreadId; |
| 220 | 270 | }) => { |
| 221 | − const api = ensureNativeApi(); | |
| 222 | − if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); | |
| 271 | + if (!input.cwd || !input.environmentId) { | |
| 272 | + throw new Error("Pull request thread preparation is unavailable."); | |
| 273 | + } | |
| 274 | + const api = ensureEnvironmentApi(input.environmentId); | |
| 223 | 275 | return api.git.preparePullRequestThread({ |
| 224 | 276 | cwd: input.cwd, |
| 225 | 277 | reference: args.reference, |
| @@ -228,7 +280,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: { · 2 unmodified lines | ||
| 228 | 280 | }); |
| 229 | 281 | }, |
| 230 | 282 | onSuccess: async () => { |
| 231 | − await invalidateGitBranchQueries(input.queryClient, input.cwd); | |
| 283 | + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | |
| 232 | 284 | }, |
| 233 | 285 | }); |
| 234 | 286 | } |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import type { GitStatusResult } from "@t3tools/contracts"; | |
| 1 | +import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts"; | |
| 2 | 2 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { |
| @@ -16,6 +16,10 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even · 11 unmodified lines | ||
| 16 | 16 | } |
| 17 | 17 | |
| 18 | 18 | const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); |
| 19 | +const ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); | |
| 20 | +const OTHER_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-remote"); | |
| 21 | +const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; | |
| 22 | +const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; | |
| 19 | 23 | |
| 20 | 24 | const BASE_STATUS: GitStatusResult = { |
| 21 | 25 | isRepo: true, |
| @@ -55,7 +59,7 @@ afterEach(() => { · 33 unmodified lines | ||
| 55 | 59 | |
| 56 | 60 | describe("gitStatusState", () => { |
| 57 | 61 | it("starts fresh cwd state in a pending state", () => { |
| 58 | − expect(getGitStatusSnapshot("/fresh")).toEqual({ | |
| 62 | + expect(getGitStatusSnapshot(FRESH_TARGET)).toEqual({ | |
| 59 | 63 | data: null, |
| 60 | 64 | error: null, |
| 61 | 65 | cause: null, |
| @@ -64,11 +68,11 @@ describe("gitStatusState", () => { · 2 unmodified lines | ||
| 64 | 68 | }); |
| 65 | 69 | |
| 66 | 70 | it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => { |
| 67 | − const releaseA = watchGitStatus("/repo", gitClient); | |
| 68 | − const releaseB = watchGitStatus("/repo", gitClient); | |
| 71 | + const releaseA = watchGitStatus(TARGET, gitClient); | |
| 72 | + const releaseB = watchGitStatus(TARGET, gitClient); | |
| 69 | 73 | |
| 70 | 74 | expect(gitClient.onStatus).toHaveBeenCalledOnce(); |
| 71 | − expect(getGitStatusSnapshot("/repo")).toEqual({ | |
| 75 | + expect(getGitStatusSnapshot(TARGET)).toEqual({ | |
| 72 | 76 | data: null, |
| 73 | 77 | error: null, |
| 74 | 78 | cause: null, |
| @@ -77,7 +81,7 @@ describe("gitStatusState", () => { · 2 unmodified lines | ||
| 77 | 81 | |
| 78 | 82 | emitGitStatus(BASE_STATUS); |
| 79 | 83 | |
| 80 | − expect(getGitStatusSnapshot("/repo")).toEqual({ | |
| 84 | + expect(getGitStatusSnapshot(TARGET)).toEqual({ | |
| 81 | 85 | data: BASE_STATUS, |
| 82 | 86 | error: null, |
| 83 | 87 | cause: null, |
| @@ -92,15 +96,15 @@ describe("gitStatusState", () => { · 8 unmodified lines | ||
| 92 | 96 | }); |
| 93 | 97 | |
| 94 | 98 | it("refreshes git status through the unary RPC without restarting the stream", async () => { |
| 95 | − const release = watchGitStatus("/repo", gitClient); | |
| 99 | + const release = watchGitStatus(TARGET, gitClient); | |
| 96 | 100 | |
| 97 | 101 | emitGitStatus(BASE_STATUS); |
| 98 | − const refreshed = await refreshGitStatus("/repo", gitClient); | |
| 102 | + const refreshed = await refreshGitStatus(TARGET, gitClient); | |
| 99 | 103 | |
| 100 | 104 | expect(gitClient.onStatus).toHaveBeenCalledOnce(); |
| 101 | 105 | expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); |
| 102 | 106 | expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" }); |
| 103 | − expect(getGitStatusSnapshot("/repo")).toEqual({ | |
| 107 | + expect(getGitStatusSnapshot(TARGET)).toEqual({ | |
| 104 | 108 | data: BASE_STATUS, |
| 105 | 109 | error: null, |
| 106 | 110 | cause: null, |
| @@ -109,4 +113,38 @@ describe("gitStatusState", () => { · 2 unmodified lines | ||
| 109 | 113 | |
| 110 | 114 | release(); |
| 111 | 115 | }); |
| 116 | + | |
| 117 | + it("keeps git status subscriptions isolated by environment when cwds match", () => { | |
| 118 | + const localListeners = new Set<(event: GitStatusResult) => void>(); | |
| 119 | + const remoteListeners = new Set<(event: GitStatusResult) => void>(); | |
| 120 | + const localClient = { | |
| 121 | + refreshStatus: vi.fn(), | |
| 122 | + onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => | |
| 123 | + registerListener(localListeners, listener), | |
| 124 | + ), | |
| 125 | + }; | |
| 126 | + const remoteClient = { | |
| 127 | + refreshStatus: vi.fn(), | |
| 128 | + onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => | |
| 129 | + registerListener(remoteListeners, listener), | |
| 130 | + ), | |
| 131 | + }; | |
| 132 | + const remoteTarget = { environmentId: OTHER_ENVIRONMENT_ID, cwd: "/repo" } as const; | |
| 133 | + | |
| 134 | + const releaseLocal = watchGitStatus(TARGET, localClient); | |
| 135 | + const releaseRemote = watchGitStatus(remoteTarget, remoteClient); | |
| 136 | + | |
| 137 | + for (const listener of localListeners) { | |
| 138 | + listener(BASE_STATUS); | |
| 139 | + } | |
| 140 | + for (const listener of remoteListeners) { | |
| 141 | + listener({ ...BASE_STATUS, branch: "remote-branch" }); | |
| 142 | + } | |
| 143 | + | |
| 144 | + expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status"); | |
| 145 | + expect(getGitStatusSnapshot(remoteTarget).data?.branch).toBe("remote-branch"); | |
| 146 | + | |
| 147 | + releaseLocal(); | |
| 148 | + releaseRemote(); | |
| 149 | + }); | |
| 112 | 150 | }); |
| @@ -1,11 +1,15 @@ | ||
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | −import { type GitManagerServiceError, type GitStatusResult } from "@t3tools/contracts"; | |
| 2 | +import { | |
| 3 | + type EnvironmentId, | |
| 4 | + type GitManagerServiceError, | |
| 5 | + type GitStatusResult, | |
| 6 | +} from "@t3tools/contracts"; | |
| 3 | 7 | import { Cause } from "effect"; |
| 4 | 8 | import { Atom } from "effect/unstable/reactivity"; |
| 5 | 9 | import { useEffect } from "react"; |
| 6 | 10 | |
| 7 | 11 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 8 | −import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient"; | |
| 12 | +import { getWsRpcClient, getWsRpcClientForEnvironment, type WsRpcClient } from "../wsRpcClient"; | |
| 9 | 13 | |
| 10 | 14 | export type GitStatusStreamError = GitManagerServiceError; |
| 11 | 15 | |
| @@ -23,6 +27,11 @@ interface WatchedGitStatus { · 11 unmodified lines | ||
| 23 | 27 | unsubscribe: () => void; |
| 24 | 28 | } |
| 25 | 29 | |
| 30 | +export interface GitStatusTarget { | |
| 31 | + readonly environmentId: EnvironmentId | null; | |
| 32 | + readonly cwd: string | null; | |
| 33 | +} | |
| 34 | + | |
| 26 | 35 | const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({ |
| 27 | 36 | data: null, |
| 28 | 37 | error: null, |
| @@ -40,79 +49,91 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( · 11 unmodified lines | ||
| 40 | 49 | |
| 41 | 50 | const NOOP: () => void = () => undefined; |
| 42 | 51 | const watchedGitStatuses = new Map<string, WatchedGitStatus>(); |
| 43 | −const knownGitStatusCwds = new Set<string>(); | |
| 52 | +const knownGitStatusKeys = new Set<string>(); | |
| 44 | 53 | const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>(); |
| 45 | −const gitStatusLastRefreshAtByCwd = new Map<string, number>(); | |
| 54 | +const gitStatusLastRefreshAtByKey = new Map<string, number>(); | |
| 46 | 55 | |
| 47 | 56 | const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; |
| 48 | 57 | |
| 49 | −let sharedGitStatusClient: GitStatusClient | null = null; | |
| 50 | − | |
| 51 | −const gitStatusStateAtom = Atom.family((cwd: string) => { | |
| 52 | − knownGitStatusCwds.add(cwd); | |
| 58 | +const gitStatusStateAtom = Atom.family((key: string) => { | |
| 59 | + knownGitStatusKeys.add(key); | |
| 53 | 60 | return Atom.make(INITIAL_GIT_STATUS_STATE).pipe( |
| 54 | 61 | Atom.keepAlive, |
| 55 | − Atom.withLabel(`git-status:${cwd}`), | |
| 62 | + Atom.withLabel(`git-status:${key}`), | |
| 56 | 63 | ); |
| 57 | 64 | }); |
| 58 | 65 | |
| 59 | −export function getGitStatusSnapshot(cwd: string | null): GitStatusState { | |
| 60 | − if (cwd === null) { | |
| 66 | +function getGitStatusTargetKey(target: GitStatusTarget): string | null { | |
| 67 | + if (target.cwd === null) { | |
| 68 | + return null; | |
| 69 | + } | |
| 70 | + | |
| 71 | + return `${target.environmentId ?? "__default__"}:${target.cwd}`; | |
| 72 | +} | |
| 73 | + | |
| 74 | +function resolveGitStatusClient(target: GitStatusTarget): GitStatusClient { | |
| 75 | + if (target.environmentId) { | |
| 76 | + return getWsRpcClientForEnvironment(target.environmentId).git; | |
| 77 | + } | |
| 78 | + return getWsRpcClient().git; | |
| 79 | +} | |
| 80 | + | |
| 81 | +export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState { | |
| 82 | + const targetKey = getGitStatusTargetKey(target); | |
| 83 | + if (targetKey === null) { | |
| 61 | 84 | return EMPTY_GIT_STATUS_STATE; |
| 62 | 85 | } |
| 63 | 86 | |
| 64 | − return appAtomRegistry.get(gitStatusStateAtom(cwd)); | |
| 87 | + return appAtomRegistry.get(gitStatusStateAtom(targetKey)); | |
| 65 | 88 | } |
| 66 | 89 | |
| 67 | 90 | export function watchGitStatus( |
| 68 | − cwd: string | null, | |
| 69 | − client: GitStatusClient = getWsRpcClient().git, | |
| 91 | + target: GitStatusTarget, | |
| 92 | + client: GitStatusClient = resolveGitStatusClient(target), | |
| 70 | 93 | ): () => void { |
| 71 | − if (cwd === null) { | |
| 94 | + const targetKey = getGitStatusTargetKey(target); | |
| 95 | + if (targetKey === null) { | |
| 72 | 96 | return NOOP; |
| 73 | 97 | } |
| 74 | 98 | |
| 75 | − ensureGitStatusClient(client); | |
| 76 | − | |
| 77 | − const watched = watchedGitStatuses.get(cwd); | |
| 99 | + const watched = watchedGitStatuses.get(targetKey); | |
| 78 | 100 | if (watched) { |
| 79 | 101 | watched.refCount += 1; |
| 80 | − return () => unwatchGitStatus(cwd); | |
| 102 | + return () => unwatchGitStatus(targetKey); | |
| 81 | 103 | } |
| 82 | 104 | |
| 83 | − watchedGitStatuses.set(cwd, { | |
| 105 | + watchedGitStatuses.set(targetKey, { | |
| 84 | 106 | refCount: 1, |
| 85 | − unsubscribe: subscribeToGitStatus(cwd), | |
| 107 | + unsubscribe: subscribeToGitStatus(targetKey, target.cwd!, client), | |
| 86 | 108 | }); |
| 87 | 109 | |
| 88 | − return () => unwatchGitStatus(cwd); | |
| 110 | + return () => unwatchGitStatus(targetKey); | |
| 89 | 111 | } |
| 90 | 112 | |
| 91 | 113 | export function refreshGitStatus( |
| 92 | − cwd: string | null, | |
| 93 | − client: GitStatusClient = getWsRpcClient().git, | |
| 114 | + target: GitStatusTarget, | |
| 115 | + client: GitStatusClient = resolveGitStatusClient(target), | |
| 94 | 116 | ): Promise<GitStatusResult | null> { |
| 95 | − if (cwd === null) { | |
| 117 | + const targetKey = getGitStatusTargetKey(target); | |
| 118 | + if (targetKey === null || target.cwd === null) { | |
| 96 | 119 | return Promise.resolve(null); |
| 97 | 120 | } |
| 98 | 121 | |
| 99 | − ensureGitStatusClient(client); | |
| 100 | − | |
| 101 | − const currentInFlight = gitStatusRefreshInFlight.get(cwd); | |
| 122 | + const currentInFlight = gitStatusRefreshInFlight.get(targetKey); | |
| 102 | 123 | if (currentInFlight) { |
| 103 | 124 | return currentInFlight; |
| 104 | 125 | } |
| 105 | 126 | |
| 106 | − const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0; | |
| 127 | + const lastRequestedAt = gitStatusLastRefreshAtByKey.get(targetKey) ?? 0; | |
| 107 | 128 | if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) { |
| 108 | − return Promise.resolve(getGitStatusSnapshot(cwd).data); | |
| 129 | + return Promise.resolve(getGitStatusSnapshot(target).data); | |
| 109 | 130 | } |
| 110 | 131 | |
| 111 | − gitStatusLastRefreshAtByCwd.set(cwd, Date.now()); | |
| 112 | − const refreshPromise = client.refreshStatus({ cwd }).finally(() => { | |
| 113 | − gitStatusRefreshInFlight.delete(cwd); | |
| 132 | + gitStatusLastRefreshAtByKey.set(targetKey, Date.now()); | |
| 133 | + const refreshPromise = client.refreshStatus({ cwd: target.cwd }).finally(() => { | |
| 134 | + gitStatusRefreshInFlight.delete(targetKey); | |
| 114 | 135 | }); |
| 115 | − gitStatusRefreshInFlight.set(cwd, refreshPromise); | |
| 136 | + gitStatusRefreshInFlight.set(targetKey, refreshPromise); | |
| 116 | 137 | return refreshPromise; |
| 117 | 138 | } |
| 118 | 139 | |
| @@ -122,43 +143,29 @@ export function resetGitStatusStateForTests(): void { · 3 unmodified lines | ||
| 122 | 143 | } |
| 123 | 144 | watchedGitStatuses.clear(); |
| 124 | 145 | gitStatusRefreshInFlight.clear(); |
| 125 | − gitStatusLastRefreshAtByCwd.clear(); | |
| 126 | − sharedGitStatusClient = null; | |
| 146 | + gitStatusLastRefreshAtByKey.clear(); | |
| 127 | 147 | |
| 128 | − for (const cwd of knownGitStatusCwds) { | |
| 129 | − appAtomRegistry.set(gitStatusStateAtom(cwd), INITIAL_GIT_STATUS_STATE); | |
| 148 | + for (const key of knownGitStatusKeys) { | |
| 149 | + appAtomRegistry.set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE); | |
| 130 | 150 | } |
| 131 | − knownGitStatusCwds.clear(); | |
| 132 | −} | |
| 133 | − | |
| 134 | −export function useGitStatus(cwd: string | null): GitStatusState { | |
| 135 | − useEffect(() => watchGitStatus(cwd), [cwd]); | |
| 136 | − | |
| 137 | − const state = useAtomValue(cwd !== null ? gitStatusStateAtom(cwd) : EMPTY_GIT_STATUS_ATOM); | |
| 138 | − return cwd === null ? EMPTY_GIT_STATUS_STATE : state; | |
| 151 | + knownGitStatusKeys.clear(); | |
| 139 | 152 | } |
| 140 | 153 | |
| 141 | −function ensureGitStatusClient(client: GitStatusClient): void { | |
| 142 | − if (sharedGitStatusClient === client) { | |
| 143 | − return; | |
| 144 | − } | |
| 145 | − | |
| 146 | − if (sharedGitStatusClient !== null) { | |
| 147 | − resetLiveGitStatusSubscriptions(); | |
| 148 | − } | |
| 149 | − | |
| 150 | − sharedGitStatusClient = client; | |
| 151 | −} | |
| 154 | +export function useGitStatus(target: GitStatusTarget): GitStatusState { | |
| 155 | + const targetKey = getGitStatusTargetKey(target); | |
| 156 | + useEffect( | |
| 157 | + () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }), | |
| 158 | + [target.environmentId, target.cwd], | |
| 159 | + ); | |
| 152 | 160 | |
| 153 | −function resetLiveGitStatusSubscriptions(): void { | |
| 154 | − for (const watched of watchedGitStatuses.values()) { | |
| 155 | − watched.unsubscribe(); | |
| 156 | − } | |
| 157 | − watchedGitStatuses.clear(); | |
| 161 | + const state = useAtomValue( | |
| 162 | + targetKey !== null ? gitStatusStateAtom(targetKey) : EMPTY_GIT_STATUS_ATOM, | |
| 163 | + ); | |
| 164 | + return targetKey === null ? EMPTY_GIT_STATUS_STATE : state; | |
| 158 | 165 | } |
| 159 | 166 | |
| 160 | −function unwatchGitStatus(cwd: string): void { | |
| 161 | − const watched = watchedGitStatuses.get(cwd); | |
| 167 | +function unwatchGitStatus(targetKey: string): void { | |
| 168 | + const watched = watchedGitStatuses.get(targetKey); | |
| 162 | 169 | if (!watched) { |
| 163 | 170 | return; |
| 164 | 171 | } |
| @@ -169,20 +176,15 @@ function unwatchGitStatus(cwd: string): void { · 4 unmodified lines | ||
| 169 | 176 | } |
| 170 | 177 | |
| 171 | 178 | watched.unsubscribe(); |
| 172 | − watchedGitStatuses.delete(cwd); | |
| 179 | + watchedGitStatuses.delete(targetKey); | |
| 173 | 180 | } |
| 174 | 181 | |
| 175 | −function subscribeToGitStatus(cwd: string): () => void { | |
| 176 | − const client = sharedGitStatusClient; | |
| 177 | − if (!client) { | |
| 178 | − return NOOP; | |
| 179 | − } | |
| 180 | − | |
| 181 | − markGitStatusPending(cwd); | |
| 182 | +function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void { | |
| 183 | + markGitStatusPending(targetKey); | |
| 182 | 184 | return client.onStatus( |
| 183 | 185 | { cwd }, |
| 184 | 186 | (status) => { |
| 185 | − appAtomRegistry.set(gitStatusStateAtom(cwd), { | |
| 187 | + appAtomRegistry.set(gitStatusStateAtom(targetKey), { | |
| 186 | 188 | data: status, |
| 187 | 189 | error: null, |
| 188 | 190 | cause: null, |
| @@ -191,14 +193,14 @@ function subscribeToGitStatus(cwd: string): () => void { · 2 unmodified lines | ||
| 191 | 193 | }, |
| 192 | 194 | { |
| 193 | 195 | onResubscribe: () => { |
| 194 | − markGitStatusPending(cwd); | |
| 196 | + markGitStatusPending(targetKey); | |
| 195 | 197 | }, |
| 196 | 198 | }, |
| 197 | 199 | ); |
| 198 | 200 | } |
| 199 | 201 | |
| 200 | −function markGitStatusPending(cwd: string): void { | |
| 201 | − const atom = gitStatusStateAtom(cwd); | |
| 202 | +function markGitStatusPending(targetKey: string): void { | |
| 203 | + const atom = gitStatusStateAtom(targetKey); | |
| 202 | 204 | const current = appAtomRegistry.get(atom); |
| 203 | 205 | const next = |
| 204 | 206 | current.data === null |
| @@ -1,11 +1,15 @@ | ||
| 1 | −import type { ProjectSearchEntriesResult } from "@t3tools/contracts"; | |
| 1 | +import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts"; | |
| 2 | 2 | import { queryOptions } from "@tanstack/react-query"; |
| 3 | −import { ensureNativeApi } from "~/nativeApi"; | |
| 3 | +import { ensureEnvironmentApi } from "~/environmentApi"; | |
| 4 | 4 | |
| 5 | 5 | export const projectQueryKeys = { |
| 6 | 6 | all: ["projects"] as const, |
| 7 | − searchEntries: (cwd: string | null, query: string, limit: number) => | |
| 8 | − ["projects", "search-entries", cwd, query, limit] as const, | |
| 7 | + searchEntries: ( | |
| 8 | + environmentId: EnvironmentId | null, | |
| 9 | + cwd: string | null, | |
| 10 | + query: string, | |
| 11 | + limit: number, | |
| 12 | + ) => ["projects", "search-entries", environmentId ?? null, cwd, query, limit] as const, | |
| 9 | 13 | }; |
| 10 | 14 | |
| 11 | 15 | const DEFAULT_SEARCH_ENTRIES_LIMIT = 80; |
| @@ -16,6 +20,7 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { · 4 unmodified lines | ||
| 16 | 20 | }; |
| 17 | 21 | |
| 18 | 22 | export function projectSearchEntriesQueryOptions(input: { |
| 23 | + environmentId: EnvironmentId | null; | |
| 19 | 24 | cwd: string | null; |
| 20 | 25 | query: string; |
| 21 | 26 | enabled?: boolean; |
| @@ -24,19 +29,23 @@ export function projectSearchEntriesQueryOptions(input: { · 2 unmodified lines | ||
| 24 | 29 | }) { |
| 25 | 30 | const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT; |
| 26 | 31 | return queryOptions({ |
| 27 | − queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit), | |
| 32 | + queryKey: projectQueryKeys.searchEntries(input.environmentId, input.cwd, input.query, limit), | |
| 28 | 33 | queryFn: async () => { |
| 29 | − const api = ensureNativeApi(); | |
| 30 | − if (!input.cwd) { | |
| 34 | + if (!input.cwd || !input.environmentId) { | |
| 31 | 35 | throw new Error("Workspace entry search is unavailable."); |
| 32 | 36 | } |
| 37 | + const api = ensureEnvironmentApi(input.environmentId); | |
| 33 | 38 | return api.projects.searchEntries({ |
| 34 | 39 | cwd: input.cwd, |
| 35 | 40 | query: input.query, |
| 36 | 41 | limit, |
| 37 | 42 | }); |
| 38 | 43 | }, |
| 39 | − enabled: (input.enabled ?? true) && input.cwd !== null && input.query.length > 0, | |
| 44 | + enabled: | |
| 45 | + (input.enabled ?? true) && | |
| 46 | + input.environmentId !== null && | |
| 47 | + input.cwd !== null && | |
| 48 | + input.query.length > 0, | |
| 40 | 49 | staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, |
| 41 | 50 | placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, |
| 42 | 51 | }); |
| @@ -1,21 +1,22 @@ | ||
| 1 | −import { ThreadId, type NativeApi } from "@t3tools/contracts"; | |
| 1 | +import { EnvironmentId, ThreadId, type EnvironmentApi } from "@t3tools/contracts"; | |
| 2 | 2 | import { QueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 4 | 4 | import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; |
| 5 | −import * as nativeApi from "../nativeApi"; | |
| 5 | +import * as environmentApi from "../environmentApi"; | |
| 6 | 6 | |
| 7 | 7 | const threadId = ThreadId.makeUnsafe("thread-id"); |
| 8 | +const environmentId = EnvironmentId.makeUnsafe("environment-local"); | |
| 8 | 9 | |
| 9 | 10 | function mockNativeApi(input: { |
| 10 | 11 | getTurnDiff: ReturnType<typeof vi.fn>; |
| 11 | 12 | getFullThreadDiff: ReturnType<typeof vi.fn>; |
| 12 | 13 | }) { |
| 13 | − vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({ | |
| 14 | + vi.spyOn(environmentApi, "ensureEnvironmentApi").mockReturnValue({ | |
| 14 | 15 | orchestration: { |
| 15 | 16 | getTurnDiff: input.getTurnDiff, |
| 16 | 17 | getFullThreadDiff: input.getFullThreadDiff, |
| 17 | 18 | }, |
| 18 | − } as unknown as NativeApi); | |
| 19 | + } as unknown as EnvironmentApi); | |
| 19 | 20 | } |
| 20 | 21 | |
| 21 | 22 | afterEach(() => { |
| @@ -25,6 +26,7 @@ afterEach(() => { · 3 unmodified lines | ||
| 25 | 26 | describe("providerQueryKeys.checkpointDiff", () => { |
| 26 | 27 | it("includes cacheScope so reused turn counts do not collide", () => { |
| 27 | 28 | const baseInput = { |
| 29 | + environmentId, | |
| 28 | 30 | threadId, |
| 29 | 31 | fromTurnCount: 1, |
| 30 | 32 | toTurnCount: 2, |
| @@ -51,6 +53,7 @@ describe("checkpointDiffQueryOptions", () => { · 20 unmodified lines | ||
| 51 | 53 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); |
| 52 | 54 | |
| 53 | 55 | const options = checkpointDiffQueryOptions({ |
| 56 | + environmentId, | |
| 54 | 57 | threadId, |
| 55 | 58 | fromTurnCount: 3, |
| 56 | 59 | toTurnCount: 4, |
| @@ -74,6 +77,7 @@ describe("checkpointDiffQueryOptions", () => { · 17 unmodified lines | ||
| 74 | 77 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); |
| 75 | 78 | |
| 76 | 79 | const options = checkpointDiffQueryOptions({ |
| 80 | + environmentId, | |
| 77 | 81 | threadId, |
| 78 | 82 | fromTurnCount: 0, |
| 79 | 83 | toTurnCount: 2, |
| @@ -96,6 +100,7 @@ describe("checkpointDiffQueryOptions", () => { · 16 unmodified lines | ||
| 96 | 100 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); |
| 97 | 101 | |
| 98 | 102 | const options = checkpointDiffQueryOptions({ |
| 103 | + environmentId, | |
| 99 | 104 | threadId, |
| 100 | 105 | fromTurnCount: 4, |
| 101 | 106 | toTurnCount: 3, |
| @@ -113,6 +118,7 @@ describe("checkpointDiffQueryOptions", () => { · 11 unmodified lines | ||
| 113 | 118 | |
| 114 | 119 | it("retries checkpoint-not-ready errors longer than generic failures", () => { |
| 115 | 120 | const options = checkpointDiffQueryOptions({ |
| 121 | + environmentId, | |
| 116 | 122 | threadId, |
| 117 | 123 | fromTurnCount: 1, |
| 118 | 124 | toTurnCount: 2, |
| @@ -137,6 +143,7 @@ describe("checkpointDiffQueryOptions", () => { · 18 unmodified lines | ||
| 137 | 143 | |
| 138 | 144 | it("backs off longer for checkpoint-not-ready errors", () => { |
| 139 | 145 | const options = checkpointDiffQueryOptions({ |
| 146 | + environmentId, | |
| 140 | 147 | threadId, |
| 141 | 148 | fromTurnCount: 1, |
| 142 | 149 | toTurnCount: 2, |
| @@ -1,13 +1,15 @@ | ||
| 1 | 1 | import { |
| 2 | + type EnvironmentId, | |
| 2 | 3 | OrchestrationGetFullThreadDiffInput, |
| 3 | 4 | OrchestrationGetTurnDiffInput, |
| 4 | 5 | ThreadId, |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | import { queryOptions } from "@tanstack/react-query"; |
| 7 | 8 | import { Option, Schema } from "effect"; |
| 8 | −import { ensureNativeApi } from "../nativeApi"; | |
| 9 | +import { ensureEnvironmentApi } from "../environmentApi"; | |
| 9 | 10 | |
| 10 | 11 | interface CheckpointDiffQueryInput { |
| 12 | + environmentId: EnvironmentId | null; | |
| 11 | 13 | threadId: ThreadId | null; |
| 12 | 14 | fromTurnCount: number | null; |
| 13 | 15 | toTurnCount: number | null; |
| @@ -21,6 +23,7 @@ export const providerQueryKeys = { · 7 unmodified lines | ||
| 21 | 23 | [ |
| 22 | 24 | "providers", |
| 23 | 25 | "checkpointDiff", |
| 26 | + input.environmentId ?? null, | |
| 24 | 27 | input.threadId, |
| 25 | 28 | input.fromTurnCount, |
| 26 | 29 | input.toTurnCount, |
| @@ -95,10 +98,10 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { · 68 unmodified lines | ||
| 95 | 98 | return queryOptions({ |
| 96 | 99 | queryKey: providerQueryKeys.checkpointDiff(input), |
| 97 | 100 | queryFn: async () => { |
| 98 | − const api = ensureNativeApi(); | |
| 99 | − if (!input.threadId || decodedRequest._tag === "None") { | |
| 101 | + if (!input.environmentId || !input.threadId || decodedRequest._tag === "None") { | |
| 100 | 102 | throw new Error("Checkpoint diff is unavailable."); |
| 101 | 103 | } |
| 104 | + const api = ensureEnvironmentApi(input.environmentId); | |
| 102 | 105 | try { |
| 103 | 106 | if (decodedRequest.value.kind === "fullThreadDiff") { |
| 104 | 107 | return await api.orchestration.getFullThreadDiff(decodedRequest.value.input); |
| @@ -108,7 +111,11 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { · 3 unmodified lines | ||
| 108 | 111 | throw new Error(normalizeCheckpointErrorMessage(error), { cause: error }); |
| 109 | 112 | } |
| 110 | 113 | }, |
| 111 | − enabled: (input.enabled ?? true) && !!input.threadId && decodedRequest._tag === "Some", | |
| 114 | + enabled: | |
| 115 | + (input.enabled ?? true) && | |
| 116 | + !!input.environmentId && | |
| 117 | + !!input.threadId && | |
| 118 | + decodedRequest._tag === "Some", | |
| 112 | 119 | staleTime: Infinity, |
| 113 | 120 | retry: (failureCount, error) => { |
| 114 | 121 | if (isCheckpointTemporarilyUnavailable(error)) { |
| @@ -1,58 +1,65 @@ | ||
| 1 | +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | |
| 1 | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | 3 | import { describe, expect, it } from "vitest"; |
| 3 | 4 | |
| 4 | 5 | import { collectActiveTerminalThreadIds } from "./terminalStateCleanup"; |
| 5 | 6 | |
| 6 | 7 | const threadId = (id: string): ThreadId => ThreadId.makeUnsafe(id); |
| 8 | +const threadKey = (environmentId: string, id: string): string => | |
| 9 | + scopedThreadKey(scopeThreadRef(environmentId as never, threadId(id))); | |
| 7 | 10 | |
| 8 | 11 | describe("collectActiveTerminalThreadIds", () => { |
| 9 | 12 | it("retains non-deleted server threads", () => { |
| 10 | 13 | const activeThreadIds = collectActiveTerminalThreadIds({ |
| 11 | 14 | snapshotThreads: [ |
| 12 | − { id: threadId("server-1"), deletedAt: null, archivedAt: null }, | |
| 13 | − { id: threadId("server-2"), deletedAt: null, archivedAt: null }, | |
| 15 | + { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null }, | |
| 16 | + { key: threadKey("env-b", "server-2"), deletedAt: null, archivedAt: null }, | |
| 14 | 17 | ], |
| 15 | − draftThreadIds: [], | |
| 18 | + draftThreadKeys: [], | |
| 16 | 19 | }); |
| 17 | 20 | |
| 18 | − expect(activeThreadIds).toEqual(new Set([threadId("server-1"), threadId("server-2")])); | |
| 21 | + expect(activeThreadIds).toEqual( | |
| 22 | + new Set([threadKey("env-a", "server-1"), threadKey("env-b", "server-2")]), | |
| 23 | + ); | |
| 19 | 24 | }); |
| 20 | 25 | |
| 21 | 26 | it("ignores deleted and archived server threads and keeps local draft threads", () => { |
| 22 | 27 | const activeThreadIds = collectActiveTerminalThreadIds({ |
| 23 | 28 | snapshotThreads: [ |
| 24 | − { id: threadId("server-active"), deletedAt: null, archivedAt: null }, | |
| 29 | + { key: threadKey("env-a", "server-active"), deletedAt: null, archivedAt: null }, | |
| 25 | 30 | { |
| 26 | − id: threadId("server-deleted"), | |
| 31 | + key: threadKey("env-a", "server-deleted"), | |
| 27 | 32 | deletedAt: "2026-03-05T08:00:00.000Z", |
| 28 | 33 | archivedAt: null, |
| 29 | 34 | }, |
| 30 | 35 | { |
| 31 | − id: threadId("server-archived"), | |
| 36 | + key: threadKey("env-a", "server-archived"), | |
| 32 | 37 | deletedAt: null, |
| 33 | 38 | archivedAt: "2026-03-05T09:00:00.000Z", |
| 34 | 39 | }, |
| 35 | 40 | ], |
| 36 | − draftThreadIds: [threadId("local-draft")], | |
| 41 | + draftThreadKeys: [threadKey("env-a", "local-draft")], | |
| 37 | 42 | }); |
| 38 | 43 | |
| 39 | − expect(activeThreadIds).toEqual(new Set([threadId("server-active"), threadId("local-draft")])); | |
| 44 | + expect(activeThreadIds).toEqual( | |
| 45 | + new Set([threadKey("env-a", "server-active"), threadKey("env-a", "local-draft")]), | |
| 46 | + ); | |
| 40 | 47 | }); |
| 41 | 48 | |
| 42 | 49 | it("does not keep draft-linked terminal state for archived server threads", () => { |
| 43 | − const archivedThreadId = threadId("server-archived"); | |
| 50 | + const archivedThreadId = threadKey("env-a", "server-archived"); | |
| 44 | 51 | |
| 45 | 52 | const activeThreadIds = collectActiveTerminalThreadIds({ |
| 46 | 53 | snapshotThreads: [ |
| 47 | 54 | { |
| 48 | − id: archivedThreadId, | |
| 55 | + key: archivedThreadId, | |
| 49 | 56 | deletedAt: null, |
| 50 | 57 | archivedAt: "2026-03-05T09:00:00.000Z", |
| 51 | 58 | }, |
| 52 | 59 | ], |
| 53 | − draftThreadIds: [archivedThreadId, threadId("local-draft")], | |
| 60 | + draftThreadKeys: [archivedThreadId, threadKey("env-a", "local-draft")], | |
| 54 | 61 | }); |
| 55 | 62 | |
| 56 | − expect(activeThreadIds).toEqual(new Set([threadId("local-draft")])); | |
| 63 | + expect(activeThreadIds).toEqual(new Set([threadKey("env-a", "local-draft")])); | |
| 57 | 64 | }); |
| 58 | 65 | }); |
| @@ -1,35 +1,33 @@ | ||
| 1 | −import type { ThreadId } from "@t3tools/contracts"; | |
| 2 | − | |
| 3 | 1 | interface TerminalRetentionThread { |
| 4 | − id: ThreadId; | |
| 2 | + key: string; | |
| 5 | 3 | deletedAt: string | null; |
| 6 | 4 | archivedAt: string | null; |
| 7 | 5 | } |
| 8 | 6 | |
| 9 | 7 | interface CollectActiveTerminalThreadIdsInput { |
| 10 | 8 | snapshotThreads: readonly TerminalRetentionThread[]; |
| 11 | − draftThreadIds: Iterable<ThreadId>; | |
| 9 | + draftThreadKeys: Iterable<string>; | |
| 12 | 10 | } |
| 13 | 11 | |
| 14 | 12 | export function collectActiveTerminalThreadIds( |
| 15 | 13 | input: CollectActiveTerminalThreadIdsInput, |
| 16 | −): Set<ThreadId> { | |
| 17 | − const activeThreadIds = new Set<ThreadId>(); | |
| 18 | − const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.id, thread])); | |
| 14 | +): Set<string> { | |
| 15 | + const activeThreadIds = new Set<string>(); | |
| 16 | + const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread])); | |
| 19 | 17 | for (const thread of input.snapshotThreads) { |
| 20 | 18 | if (thread.deletedAt !== null) continue; |
| 21 | 19 | if (thread.archivedAt !== null) continue; |
| 22 | − activeThreadIds.add(thread.id); | |
| 20 | + activeThreadIds.add(thread.key); | |
| 23 | 21 | } |
| 24 | − for (const draftThreadId of input.draftThreadIds) { | |
| 25 | − const snapshotThread = snapshotThreadById.get(draftThreadId); | |
| 22 | + for (const draftThreadKey of input.draftThreadKeys) { | |
| 23 | + const snapshotThread = snapshotThreadById.get(draftThreadKey); | |
| 26 | 24 | if ( |
| 27 | 25 | snapshotThread && |
| 28 | 26 | (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null) |
| 29 | 27 | ) { |
| 30 | 28 | continue; |
| 31 | 29 | } |
| 32 | − activeThreadIds.add(draftThreadId); | |
| 30 | + activeThreadIds.add(draftThreadKey); | |
| 33 | 31 | } |
| 34 | 32 | return activeThreadIds; |
| 35 | 33 | } |
| @@ -1,6 +1,15 @@ | ||
| 1 | −import { assert, describe, it } from "vitest"; | |
| 1 | +import { assert, describe, expect, it, vi } from "vitest"; | |
| 2 | + | |
| 3 | +const { resolvePrimaryEnvironmentBootstrapUrlMock } = vi.hoisted(() => ({ | |
| 4 | + resolvePrimaryEnvironmentBootstrapUrlMock: vi.fn(() => "http://bootstrap.test:4321"), | |
| 5 | +})); | |
| 6 | + | |
| 7 | +vi.mock("../environmentBootstrap", () => ({ | |
| 8 | + resolvePrimaryEnvironmentBootstrapUrl: resolvePrimaryEnvironmentBootstrapUrlMock, | |
| 9 | +})); | |
| 2 | 10 | |
| 3 | 11 | import { isWindowsPlatform } from "./utils"; |
| 12 | +import { resolveServerUrl } from "./utils"; | |
| 4 | 13 | |
| 5 | 14 | describe("isWindowsPlatform", () => { |
| 6 | 15 | it("matches Windows platform identifiers", () => { |
| @@ -13,3 +22,34 @@ describe("isWindowsPlatform", () => { · 6 unmodified lines | ||
| 13 | 22 | assert.isFalse(isWindowsPlatform("darwin")); |
| 14 | 23 | }); |
| 15 | 24 | }); |
| 25 | + | |
| 26 | +describe("resolveServerUrl", () => { | |
| 27 | + it("falls back to the bootstrap environment URL when the explicit URL is empty", () => { | |
| 28 | + expect(resolveServerUrl({ url: "" })).toBe("http://bootstrap.test:4321/"); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it("uses the bootstrap environment URL when no explicit URL is provided", () => { | |
| 32 | + expect(resolveServerUrl()).toBe("http://bootstrap.test:4321/"); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("prefers an explicit URL override", () => { | |
| 36 | + expect( | |
| 37 | + resolveServerUrl({ | |
| 38 | + url: "https://override.test:9999", | |
| 39 | + protocol: "wss", | |
| 40 | + pathname: "/rpc", | |
| 41 | + searchParams: { hello: "world" }, | |
| 42 | + }), | |
| 43 | + ).toBe("wss://override.test:9999/rpc?hello=world"); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it("does not evaluate the bootstrap resolver when an explicit URL is provided", () => { | |
| 47 | + resolvePrimaryEnvironmentBootstrapUrlMock.mockImplementationOnce(() => { | |
| 48 | + throw new Error("bootstrap unavailable"); | |
| 49 | + }); | |
| 50 | + | |
| 51 | + expect(resolveServerUrl({ url: "https://override.test:9999" })).toBe( | |
| 52 | + "https://override.test:9999/", | |
| 53 | + ); | |
| 54 | + }); | |
| 55 | +}); | |
| @@ -4,6 +4,8 @@ import { type CxOptions, cx } from "class-variance-authority"; · 3 unmodified lines | ||
| 4 | 4 | import { twMerge } from "tailwind-merge"; |
| 5 | 5 | import * as Random from "effect/Random"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | +import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap"; | |
| 8 | +import { DraftId } from "../composerDraftStore"; | |
| 7 | 9 | |
| 8 | 10 | export function cn(...inputs: CxOptions) { |
| 9 | 11 | return twMerge(cx(inputs)); |
| @@ -34,17 +36,11 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); · 24 unmodified lines | ||
| 34 | 36 | |
| 35 | 37 | export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); |
| 36 | 38 | |
| 39 | +export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID()); | |
| 40 | + | |
| 37 | 41 | export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); |
| 38 | 42 | |
| 39 | 43 | const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty); |
| 40 | −const firstNonEmptyString = (...values: unknown[]): string => { | |
| 41 | − for (const value of values) { | |
| 42 | − if (isNonEmptyString(value)) { | |
| 43 | − return value; | |
| 44 | − } | |
| 45 | − } | |
| 46 | − throw new Error("No non-empty string provided"); | |
| 47 | −}; | |
| 48 | 44 | |
| 49 | 45 | export const resolveServerUrl = (options?: { |
| 50 | 46 | url?: string | undefined; |
| @@ -52,12 +48,9 @@ export const resolveServerUrl = (options?: { · 1 unmodified line | ||
| 52 | 48 | pathname?: string | undefined; |
| 53 | 49 | searchParams?: Record<string, string> | undefined; |
| 54 | 50 | }): string => { |
| 55 | − const rawUrl = firstNonEmptyString( | |
| 56 | − options?.url, | |
| 57 | − window.desktopBridge?.getWsUrl(), | |
| 58 | − import.meta.env.VITE_WS_URL, | |
| 59 | − window.location.origin, | |
| 60 | − ); | |
| 51 | + const rawUrl = isNonEmptyString(options?.url) | |
| 52 | + ? options.url | |
| 53 | + : resolvePrimaryEnvironmentBootstrapUrl(); | |
| 61 | 54 | |
| 62 | 55 | const parsedUrl = new URL(rawUrl); |
| 63 | 56 | if (options?.protocol) { |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | CommandId, |
| 3 | 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | 4 | type DesktopBridge, |
| 5 | + EnvironmentId, | |
| 5 | 6 | EventId, |
| 6 | 7 | type GitStatusResult, |
| 7 | 8 | ProjectId, |
| @@ -94,6 +95,17 @@ const rpcClientMock = { · 86 unmodified lines | ||
| 94 | 95 | vi.mock("./wsRpcClient", () => { |
| 95 | 96 | return { |
| 96 | 97 | getWsRpcClient: () => rpcClientMock, |
| 98 | + getPrimaryWsRpcClientEntry: () => ({ | |
| 99 | + key: "primary", | |
| 100 | + knownEnvironment: { | |
| 101 | + id: "primary", | |
| 102 | + label: "Primary", | |
| 103 | + source: "manual", | |
| 104 | + target: { type: "ws", wsUrl: "ws://localhost:3000" }, | |
| 105 | + }, | |
| 106 | + client: rpcClientMock, | |
| 107 | + environmentId: null, | |
| 108 | + }), | |
| 97 | 109 | __resetWsRpcClientForTests: vi.fn(), |
| 98 | 110 | }; |
| 99 | 111 | }); |
| @@ -121,6 +133,7 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn · 21 unmodified lines | ||
| 121 | 133 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { |
| 122 | 134 | return { |
| 123 | 135 | getWsUrl: () => null, |
| 136 | + getLocalEnvironmentBootstrap: () => null, | |
| 124 | 137 | pickFolder: async () => null, |
| 125 | 138 | confirm: async () => true, |
| 126 | 139 | setTheme: async () => undefined, |
| @@ -157,7 +170,21 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [ · 30 unmodified lines | ||
| 157 | 170 | }, |
| 158 | 171 | ]; |
| 159 | 172 | |
| 173 | +const baseEnvironment = { | |
| 174 | + environmentId: EnvironmentId.makeUnsafe("environment-local"), | |
| 175 | + label: "Local environment", | |
| 176 | + platform: { | |
| 177 | + os: "darwin" as const, | |
| 178 | + arch: "arm64" as const, | |
| 179 | + }, | |
| 180 | + serverVersion: "0.0.0-test", | |
| 181 | + capabilities: { | |
| 182 | + repositoryIdentity: true, | |
| 183 | + }, | |
| 184 | +}; | |
| 185 | + | |
| 160 | 186 | const baseServerConfig: ServerConfig = { |
| 187 | + environment: baseEnvironment, | |
| 161 | 188 | cwd: "/tmp/workspace", |
| 162 | 189 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", |
| 163 | 190 | keybindings: [], |
| @@ -200,12 +227,12 @@ afterEach(() => { · 36 unmodified lines | ||
| 200 | 227 | vi.restoreAllMocks(); |
| 201 | 228 | }); |
| 202 | 229 | |
| 203 | −describe("wsNativeApi", () => { | |
| 230 | +describe("wsApi", () => { | |
| 204 | 231 | it("forwards server config fetches directly to the RPC client", async () => { |
| 205 | 232 | rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); |
| 206 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 233 | + const { createLocalApi } = await import("./localApi"); | |
| 207 | 234 | |
| 208 | − const api = createWsNativeApi(); | |
| 235 | + const api = createLocalApi(); | |
| 209 | 236 | |
| 210 | 237 | await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); |
| 211 | 238 | expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); |
| @@ -214,9 +241,9 @@ describe("wsNativeApi", () => { · 2 unmodified lines | ||
| 214 | 241 | }); |
| 215 | 242 | |
| 216 | 243 | it("forwards terminal and orchestration stream events", async () => { |
| 217 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 244 | + const { createEnvironmentApi } = await import("./environmentApi"); | |
| 218 | 245 | |
| 219 | − const api = createWsNativeApi(); | |
| 246 | + const api = createEnvironmentApi(rpcClientMock as never); | |
| 220 | 247 | const onTerminalEvent = vi.fn(); |
| 221 | 248 | const onDomainEvent = vi.fn(); |
| 222 | 249 | |
| @@ -263,9 +290,9 @@ describe("wsNativeApi", () => { · 40 unmodified lines | ||
| 263 | 290 | }); |
| 264 | 291 | |
| 265 | 292 | it("forwards git status stream events", async () => { |
| 266 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 293 | + const { createEnvironmentApi } = await import("./environmentApi"); | |
| 267 | 294 | |
| 268 | − const api = createWsNativeApi(); | |
| 295 | + const api = createEnvironmentApi(rpcClientMock as never); | |
| 269 | 296 | const onStatus = vi.fn(); |
| 270 | 297 | |
| 271 | 298 | api.git.onStatus({ cwd: "/repo" }, onStatus); |
| @@ -279,9 +306,9 @@ describe("wsNativeApi", () => { · 7 unmodified lines | ||
| 279 | 306 | |
| 280 | 307 | it("forwards git status refreshes directly to the RPC client", async () => { |
| 281 | 308 | rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); |
| 282 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 309 | + const { createEnvironmentApi } = await import("./environmentApi"); | |
| 283 | 310 | |
| 284 | − const api = createWsNativeApi(); | |
| 311 | + const api = createEnvironmentApi(rpcClientMock as never); | |
| 285 | 312 | |
| 286 | 313 | await api.git.refreshStatus({ cwd: "/repo" }); |
| 287 | 314 | |
| @@ -289,9 +316,9 @@ describe("wsNativeApi", () => { · 1 unmodified line | ||
| 289 | 316 | }); |
| 290 | 317 | |
| 291 | 318 | it("forwards orchestration stream subscription options to the RPC client", async () => { |
| 292 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 319 | + const { createEnvironmentApi } = await import("./environmentApi"); | |
| 293 | 320 | |
| 294 | − const api = createWsNativeApi(); | |
| 321 | + const api = createEnvironmentApi(rpcClientMock as never); | |
| 295 | 322 | const onDomainEvent = vi.fn(); |
| 296 | 323 | const onResubscribe = vi.fn(); |
| 297 | 324 | |
| @@ -304,9 +331,9 @@ describe("wsNativeApi", () => { · 6 unmodified lines | ||
| 304 | 331 | |
| 305 | 332 | it("sends orchestration dispatch commands as the direct RPC payload", async () => { |
| 306 | 333 | rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); |
| 307 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 334 | + const { createEnvironmentApi } = await import("./environmentApi"); | |
| 308 | 335 | |
| 309 | − const api = createWsNativeApi(); | |
| 336 | + const api = createEnvironmentApi(rpcClientMock as never); | |
| 310 | 337 | const command = { |
| 311 | 338 | type: "project.create", |
| 312 | 339 | commandId: CommandId.makeUnsafe("cmd-1"), |
| @@ -326,9 +353,9 @@ describe("wsNativeApi", () => { · 13 unmodified lines | ||
| 326 | 353 | |
| 327 | 354 | it("forwards workspace file writes to the project RPC", async () => { |
| 328 | 355 | rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); |
| 329 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 356 | + const { createEnvironmentApi } = await import("./environmentApi"); | |
| 330 | 357 | |
| 331 | − const api = createWsNativeApi(); | |
| 358 | + const api = createEnvironmentApi(rpcClientMock as never); | |
| 332 | 359 | await api.projects.writeFile({ |
| 333 | 360 | cwd: "/tmp/project", |
| 334 | 361 | relativePath: "plan.md", |
| @@ -344,9 +371,9 @@ describe("wsNativeApi", () => { · 9 unmodified lines | ||
| 344 | 371 | |
| 345 | 372 | it("forwards full-thread diff requests to the orchestration RPC", async () => { |
| 346 | 373 | rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); |
| 347 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 374 | + const { createEnvironmentApi } = await import("./environmentApi"); | |
| 348 | 375 | |
| 349 | − const api = createWsNativeApi(); | |
| 376 | + const api = createEnvironmentApi(rpcClientMock as never); | |
| 350 | 377 | await api.orchestration.getFullThreadDiff({ |
| 351 | 378 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 352 | 379 | toTurnCount: 1, |
| @@ -366,9 +393,9 @@ describe("wsNativeApi", () => { · 13 unmodified lines | ||
| 366 | 393 | }, |
| 367 | 394 | ]; |
| 368 | 395 | rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); |
| 369 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 396 | + const { createLocalApi } = await import("./localApi"); | |
| 370 | 397 | |
| 371 | − const api = createWsNativeApi(); | |
| 398 | + const api = createLocalApi(); | |
| 372 | 399 | |
| 373 | 400 | await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); |
| 374 | 401 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); |
| @@ -380,9 +407,9 @@ describe("wsNativeApi", () => { · 5 unmodified lines | ||
| 380 | 407 | enableAssistantStreaming: true, |
| 381 | 408 | }; |
| 382 | 409 | rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); |
| 383 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 410 | + const { createLocalApi } = await import("./localApi"); | |
| 384 | 411 | |
| 385 | − const api = createWsNativeApi(); | |
| 412 | + const api = createLocalApi(); | |
| 386 | 413 | |
| 387 | 414 | await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( |
| 388 | 415 | nextSettings, |
| @@ -396,8 +423,8 @@ describe("wsNativeApi", () => { · 7 unmodified lines | ||
| 396 | 423 | const showContextMenu = vi.fn().mockResolvedValue("delete"); |
| 397 | 424 | getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); |
| 398 | 425 | |
| 399 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 400 | − const api = createWsNativeApi(); | |
| 426 | + const { createLocalApi } = await import("./localApi"); | |
| 427 | + const api = createLocalApi(); | |
| 401 | 428 | const items = [{ id: "delete", label: "Delete" }] as const; |
| 402 | 429 | |
| 403 | 430 | await expect(api.contextMenu.show(items)).resolves.toBe("delete"); |
| @@ -406,9 +433,9 @@ describe("wsNativeApi", () => { · 2 unmodified lines | ||
| 406 | 433 | |
| 407 | 434 | it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { |
| 408 | 435 | showContextMenuFallbackMock.mockResolvedValue("rename"); |
| 409 | − const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 436 | + const { createLocalApi } = await import("./localApi"); | |
| 410 | 437 | |
| 411 | − const api = createWsNativeApi(); | |
| 438 | + const api = createLocalApi(); | |
| 412 | 439 | const items = [{ id: "rename", label: "Rename" }] as const; |
| 413 | 440 | |
| 414 | 441 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); |
| @@ -0,0 +1,94 @@ | ||
| 1 | +import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; | |
| 2 | + | |
| 3 | +import { resetGitStatusStateForTests } from "./lib/gitStatusState"; | |
| 4 | + | |
| 5 | +import { __resetWsRpcAtomClientForTests } from "./rpc/client"; | |
| 6 | +import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; | |
| 7 | +import { resetServerStateForTests } from "./rpc/serverState"; | |
| 8 | +import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; | |
| 9 | +import { getPrimaryWsRpcClientEntry, WsRpcClient, __resetWsRpcClientForTests } from "./wsRpcClient"; | |
| 10 | +import { showContextMenuFallback } from "./contextMenuFallback"; | |
| 11 | + | |
| 12 | +let cachedApi: LocalApi | undefined; | |
| 13 | + | |
| 14 | +export function createLocalApi( | |
| 15 | + rpcClient: WsRpcClient = getPrimaryWsRpcClientEntry().client, | |
| 16 | +): LocalApi { | |
| 17 | + return { | |
| 18 | + dialogs: { | |
| 19 | + pickFolder: async () => { | |
| 20 | + if (!window.desktopBridge) return null; | |
| 21 | + return window.desktopBridge.pickFolder(); | |
| 22 | + }, | |
| 23 | + confirm: async (message) => { | |
| 24 | + if (window.desktopBridge) { | |
| 25 | + return window.desktopBridge.confirm(message); | |
| 26 | + } | |
| 27 | + return window.confirm(message); | |
| 28 | + }, | |
| 29 | + }, | |
| 30 | + shell: { | |
| 31 | + openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), | |
| 32 | + openExternal: async (url) => { | |
| 33 | + if (window.desktopBridge) { | |
| 34 | + const opened = await window.desktopBridge.openExternal(url); | |
| 35 | + if (!opened) { | |
| 36 | + throw new Error("Unable to open link."); | |
| 37 | + } | |
| 38 | + return; | |
| 39 | + } | |
| 40 | + | |
| 41 | + window.open(url, "_blank", "noopener,noreferrer"); | |
| 42 | + }, | |
| 43 | + }, | |
| 44 | + contextMenu: { | |
| 45 | + show: async <T extends string>( | |
| 46 | + items: readonly ContextMenuItem<T>[], | |
| 47 | + position?: { x: number; y: number }, | |
| 48 | + ): Promise<T | null> => { | |
| 49 | + if (window.desktopBridge) { | |
| 50 | + return window.desktopBridge.showContextMenu(items, position) as Promise<T | null>; | |
| 51 | + } | |
| 52 | + return showContextMenuFallback(items, position); | |
| 53 | + }, | |
| 54 | + }, | |
| 55 | + server: { | |
| 56 | + getConfig: rpcClient.server.getConfig, | |
| 57 | + refreshProviders: rpcClient.server.refreshProviders, | |
| 58 | + upsertKeybinding: rpcClient.server.upsertKeybinding, | |
| 59 | + getSettings: rpcClient.server.getSettings, | |
| 60 | + updateSettings: rpcClient.server.updateSettings, | |
| 61 | + }, | |
| 62 | + }; | |
| 63 | +} | |
| 64 | + | |
| 65 | +export function readLocalApi(): LocalApi | undefined { | |
| 66 | + if (typeof window === "undefined") return undefined; | |
| 67 | + if (cachedApi) return cachedApi; | |
| 68 | + | |
| 69 | + if (window.nativeApi) { | |
| 70 | + cachedApi = window.nativeApi; | |
| 71 | + return cachedApi; | |
| 72 | + } | |
| 73 | + | |
| 74 | + cachedApi = createLocalApi(); | |
| 75 | + return cachedApi; | |
| 76 | +} | |
| 77 | + | |
| 78 | +export function ensureLocalApi(): LocalApi { | |
| 79 | + const api = readLocalApi(); | |
| 80 | + if (!api) { | |
| 81 | + throw new Error("Local API not found"); | |
| 82 | + } | |
| 83 | + return api; | |
| 84 | +} | |
| 85 | + | |
| 86 | +export async function __resetLocalApiForTests() { | |
| 87 | + cachedApi = undefined; | |
| 88 | + await __resetWsRpcAtomClientForTests(); | |
| 89 | + await __resetWsRpcClientForTests(); | |
| 90 | + resetGitStatusStateForTests(); | |
| 91 | + resetRequestLatencyStateForTests(); | |
| 92 | + resetServerStateForTests(); | |
| 93 | + resetWsConnectionStateForTests(); | |
| 94 | +} | |
| @@ -0,0 +1,19 @@ | ||
| 1 | +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; | |
| 2 | +import type { ScopedProjectRef } from "@t3tools/contracts"; | |
| 3 | +import type { Project } from "./types"; | |
| 4 | + | |
| 5 | +export function deriveLogicalProjectKey( | |
| 6 | + project: Pick<Project, "environmentId" | "id" | "repositoryIdentity">, | |
| 7 | +): string { | |
| 8 | + return ( | |
| 9 | + project.repositoryIdentity?.canonicalKey ?? | |
| 10 | + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) | |
| 11 | + ); | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function deriveLogicalProjectKeyFromRef( | |
| 15 | + projectRef: ScopedProjectRef, | |
| 16 | + project: Pick<Project, "repositoryIdentity"> | null | undefined, | |
| 17 | +): string { | |
| 18 | + return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); | |
| 19 | +} | |
| @@ -1,31 +0,0 @@ | ||
| 1 | −import type { NativeApi } from "@t3tools/contracts"; | |
| 2 | − | |
| 3 | −import { __resetWsNativeApiForTests, createWsNativeApi } from "./wsNativeApi"; | |
| 4 | − | |
| 5 | −let cachedApi: NativeApi | undefined; | |
| 6 | − | |
| 7 | −export function readNativeApi(): NativeApi | undefined { | |
| 8 | − if (typeof window === "undefined") return undefined; | |
| 9 | − if (cachedApi) return cachedApi; | |
| 10 | − | |
| 11 | − if (window.nativeApi) { | |
| 12 | − cachedApi = window.nativeApi; | |
| 13 | − return cachedApi; | |
| 14 | − } | |
| 15 | − | |
| 16 | − cachedApi = createWsNativeApi(); | |
| 17 | − return cachedApi; | |
| 18 | −} | |
| 19 | − | |
| 20 | −export function ensureNativeApi(): NativeApi { | |
| 21 | − const api = readNativeApi(); | |
| 22 | − if (!api) { | |
| 23 | − throw new Error("Native API not found"); | |
| 24 | − } | |
| 25 | − return api; | |
| 26 | −} | |
| 27 | − | |
| 28 | −export async function __resetNativeApiForTests() { | |
| 29 | − cachedApi = undefined; | |
| 30 | − await __resetWsNativeApiForTests(); | |
| 31 | −} | |
| @@ -68,7 +68,7 @@ describe("deriveOrchestrationBatchEffects", () => { · 67 unmodified lines | ||
| 68 | 68 | }), |
| 69 | 69 | ]); |
| 70 | 70 | |
| 71 | − expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]); | |
| 71 | + expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]); | |
| 72 | 72 | expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); |
| 73 | 73 | expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]); |
| 74 | 74 | expect(effects.needsProviderInvalidation).toBe(false); |
| @@ -106,7 +106,7 @@ describe("deriveOrchestrationBatchEffects", () => { · 31 unmodified lines | ||
| 106 | 106 | }), |
| 107 | 107 | ]); |
| 108 | 108 | |
| 109 | − expect(effects.clearPromotedDraftThreadIds).toEqual([threadId]); | |
| 109 | + expect(effects.promoteDraftThreadIds).toEqual([threadId]); | |
| 110 | 110 | expect(effects.clearDeletedThreadIds).toEqual([]); |
| 111 | 111 | expect(effects.removeTerminalStateThreadIds).toEqual([]); |
| 112 | 112 | expect(effects.needsProviderInvalidation).toBe(true); |
| @@ -127,7 +127,7 @@ describe("deriveOrchestrationBatchEffects", () => { · 14 unmodified lines | ||
| 127 | 127 | }), |
| 128 | 128 | ]); |
| 129 | 129 | |
| 130 | − expect(effects.clearPromotedDraftThreadIds).toEqual([]); | |
| 130 | + expect(effects.promoteDraftThreadIds).toEqual([]); | |
| 131 | 131 | expect(effects.clearDeletedThreadIds).toEqual([]); |
| 132 | 132 | expect(effects.removeTerminalStateThreadIds).toEqual([]); |
| 133 | 133 | }); |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | 3 | export interface OrchestrationBatchEffects { |
| 4 | − clearPromotedDraftThreadIds: ThreadId[]; | |
| 4 | + promoteDraftThreadIds: ThreadId[]; | |
| 5 | 5 | clearDeletedThreadIds: ThreadId[]; |
| 6 | 6 | removeTerminalStateThreadIds: ThreadId[]; |
| 7 | 7 | needsProviderInvalidation: boolean; |
| @@ -70,12 +70,12 @@ export function deriveOrchestrationBatchEffects( · 62 unmodified lines | ||
| 70 | 70 | } |
| 71 | 71 | } |
| 72 | 72 | |
| 73 | − const clearPromotedDraftThreadIds: ThreadId[] = []; | |
| 73 | + const promoteDraftThreadIds: ThreadId[] = []; | |
| 74 | 74 | const clearDeletedThreadIds: ThreadId[] = []; |
| 75 | 75 | const removeTerminalStateThreadIds: ThreadId[] = []; |
| 76 | 76 | for (const [threadId, effect] of threadLifecycleEffects) { |
| 77 | 77 | if (effect.clearPromotedDraft) { |
| 78 | − clearPromotedDraftThreadIds.push(threadId); | |
| 78 | + promoteDraftThreadIds.push(threadId); | |
| 79 | 79 | } |
| 80 | 80 | if (effect.clearDeletedThread) { |
| 81 | 81 | clearDeletedThreadIds.push(threadId); |
| @@ -86,7 +86,7 @@ export function deriveOrchestrationBatchEffects( · 4 unmodified lines | ||
| 86 | 86 | } |
| 87 | 87 | |
| 88 | 88 | return { |
| 89 | − clearPromotedDraftThreadIds, | |
| 89 | + promoteDraftThreadIds, | |
| 90 | 90 | clearDeletedThreadIds, |
| 91 | 91 | removeTerminalStateThreadIds, |
| 92 | 92 | needsProviderInvalidation, |
| @@ -14,7 +14,8 @@ import { Route as ChatRouteImport } from './routes/_chat' · 13 unmodified lines | ||
| 14 | 14 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 15 | 15 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 16 | 16 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| 17 | −import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' | |
| 17 | +import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' | |
| 18 | +import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' | |
| 18 | 19 | |
| 19 | 20 | const SettingsRoute = SettingsRouteImport.update({ |
| 20 | 21 | id: '/settings', |
| @@ -40,58 +41,70 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ · 19 unmodified lines | ||
| 40 | 41 | path: '/archived', |
| 41 | 42 | getParentRoute: () => SettingsRoute, |
| 42 | 43 | } as any) |
| 43 | −const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ | |
| 44 | − id: '/$threadId', | |
| 45 | − path: '/$threadId', | |
| 44 | +const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ | |
| 45 | + id: '/draft/$draftId', | |
| 46 | + path: '/draft/$draftId', | |
| 46 | 47 | getParentRoute: () => ChatRoute, |
| 47 | 48 | } as any) |
| 49 | +const ChatEnvironmentIdThreadIdRoute = | |
| 50 | + ChatEnvironmentIdThreadIdRouteImport.update({ | |
| 51 | + id: '/$environmentId/$threadId', | |
| 52 | + path: '/$environmentId/$threadId', | |
| 53 | + getParentRoute: () => ChatRoute, | |
| 54 | + } as any) | |
| 48 | 55 | |
| 49 | 56 | export interface FileRoutesByFullPath { |
| 50 | 57 | '/': typeof ChatIndexRoute |
| 51 | 58 | '/settings': typeof SettingsRouteWithChildren |
| 52 | − '/$threadId': typeof ChatThreadIdRoute | |
| 53 | 59 | '/settings/archived': typeof SettingsArchivedRoute |
| 54 | 60 | '/settings/general': typeof SettingsGeneralRoute |
| 61 | + '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | |
| 62 | + '/draft/$draftId': typeof ChatDraftDraftIdRoute | |
| 55 | 63 | } |
| 56 | 64 | export interface FileRoutesByTo { |
| 57 | 65 | '/settings': typeof SettingsRouteWithChildren |
| 58 | − '/$threadId': typeof ChatThreadIdRoute | |
| 59 | 66 | '/settings/archived': typeof SettingsArchivedRoute |
| 60 | 67 | '/settings/general': typeof SettingsGeneralRoute |
| 61 | 68 | '/': typeof ChatIndexRoute |
| 69 | + '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | |
| 70 | + '/draft/$draftId': typeof ChatDraftDraftIdRoute | |
| 62 | 71 | } |
| 63 | 72 | export interface FileRoutesById { |
| 64 | 73 | __root__: typeof rootRouteImport |
| 65 | 74 | '/_chat': typeof ChatRouteWithChildren |
| 66 | 75 | '/settings': typeof SettingsRouteWithChildren |
| 67 | − '/_chat/$threadId': typeof ChatThreadIdRoute | |
| 68 | 76 | '/settings/archived': typeof SettingsArchivedRoute |
| 69 | 77 | '/settings/general': typeof SettingsGeneralRoute |
| 70 | 78 | '/_chat/': typeof ChatIndexRoute |
| 79 | + '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | |
| 80 | + '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute | |
| 71 | 81 | } |
| 72 | 82 | export interface FileRouteTypes { |
| 73 | 83 | fileRoutesByFullPath: FileRoutesByFullPath |
| 74 | 84 | fullPaths: |
| 75 | 85 | | '/' |
| 76 | 86 | | '/settings' |
| 77 | − | '/$threadId' | |
| 78 | 87 | | '/settings/archived' |
| 79 | 88 | | '/settings/general' |
| 89 | + | '/$environmentId/$threadId' | |
| 90 | + | '/draft/$draftId' | |
| 80 | 91 | fileRoutesByTo: FileRoutesByTo |
| 81 | 92 | to: |
| 82 | 93 | | '/settings' |
| 83 | − | '/$threadId' | |
| 84 | 94 | | '/settings/archived' |
| 85 | 95 | | '/settings/general' |
| 86 | 96 | | '/' |
| 97 | + | '/$environmentId/$threadId' | |
| 98 | + | '/draft/$draftId' | |
| 87 | 99 | id: |
| 88 | 100 | | '__root__' |
| 89 | 101 | | '/_chat' |
| 90 | 102 | | '/settings' |
| 91 | − | '/_chat/$threadId' | |
| 92 | 103 | | '/settings/archived' |
| 93 | 104 | | '/settings/general' |
| 94 | 105 | | '/_chat/' |
| 106 | + | '/_chat/$environmentId/$threadId' | |
| 107 | + | '/_chat/draft/$draftId' | |
| 95 | 108 | fileRoutesById: FileRoutesById |
| 96 | 109 | } |
| 97 | 110 | export interface RootRouteChildren { |
| @@ -136,24 +149,33 @@ declare module '@tanstack/react-router' { · 38 unmodified lines | ||
| 136 | 149 | preLoaderRoute: typeof SettingsArchivedRouteImport |
| 137 | 150 | parentRoute: typeof SettingsRoute |
| 138 | 151 | } |
| 139 | − '/_chat/$threadId': { | |
| 140 | − id: '/_chat/$threadId' | |
| 141 | − path: '/$threadId' | |
| 142 | − fullPath: '/$threadId' | |
| 143 | − preLoaderRoute: typeof ChatThreadIdRouteImport | |
| 152 | + '/_chat/draft/$draftId': { | |
| 153 | + id: '/_chat/draft/$draftId' | |
| 154 | + path: '/draft/$draftId' | |
| 155 | + fullPath: '/draft/$draftId' | |
| 156 | + preLoaderRoute: typeof ChatDraftDraftIdRouteImport | |
| 157 | + parentRoute: typeof ChatRoute | |
| 158 | + } | |
| 159 | + '/_chat/$environmentId/$threadId': { | |
| 160 | + id: '/_chat/$environmentId/$threadId' | |
| 161 | + path: '/$environmentId/$threadId' | |
| 162 | + fullPath: '/$environmentId/$threadId' | |
| 163 | + preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport | |
| 144 | 164 | parentRoute: typeof ChatRoute |
| 145 | 165 | } |
| 146 | 166 | } |
| 147 | 167 | } |
| 148 | 168 | |
| 149 | 169 | interface ChatRouteChildren { |
| 150 | − ChatThreadIdRoute: typeof ChatThreadIdRoute | |
| 151 | 170 | ChatIndexRoute: typeof ChatIndexRoute |
| 171 | + ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute | |
| 172 | + ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute | |
| 152 | 173 | } |
| 153 | 174 | |
| 154 | 175 | const ChatRouteChildren: ChatRouteChildren = { |
| 155 | − ChatThreadIdRoute: ChatThreadIdRoute, | |
| 156 | 176 | ChatIndexRoute: ChatIndexRoute, |
| 177 | + ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, | |
| 178 | + ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, | |
| 157 | 179 | } |
| 158 | 180 | |
| 159 | 181 | const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) |
| @@ -1,8 +1,15 @@ | ||
| 1 | 1 | import { |
| 2 | + type EnvironmentId, | |
| 2 | 3 | OrchestrationEvent, |
| 3 | 4 | type ServerLifecycleWelcomePayload, |
| 4 | 5 | ThreadId, |
| 5 | 6 | } from "@t3tools/contracts"; |
| 7 | +import { | |
| 8 | + scopedProjectKey, | |
| 9 | + scopedThreadKey, | |
| 10 | + scopeProjectRef, | |
| 11 | + scopeThreadRef, | |
| 12 | +} from "@t3tools/client-runtime"; | |
| 6 | 13 | import { |
| 7 | 14 | Outlet, |
| 8 | 15 | createRootRouteWithContext, |
| @@ -10,7 +17,7 @@ import { · 1 unmodified line | ||
| 10 | 17 | useNavigate, |
| 11 | 18 | useLocation, |
| 12 | 19 | } from "@tanstack/react-router"; |
| 13 | −import { useEffect, useEffectEvent, useRef } from "react"; | |
| 20 | +import { useEffect, useEffectEvent, useRef, useState } from "react"; | |
| 14 | 21 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 15 | 22 | import { Throttler } from "@tanstack/react-pacer"; |
| 16 | 23 | |
| @@ -24,7 +31,7 @@ import { · 7 unmodified lines | ||
| 24 | 31 | import { Button } from "../components/ui/button"; |
| 25 | 32 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; |
| 26 | 33 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 27 | −import { readNativeApi } from "../nativeApi"; | |
| 34 | +import { readLocalApi } from "../localApi"; | |
| 28 | 35 | import { |
| 29 | 36 | getServerConfigUpdatedNotification, |
| 30 | 37 | ServerConfigUpdatedNotification, |
| @@ -34,11 +41,15 @@ import { · 3 unmodified lines | ||
| 34 | 41 | useServerWelcomeSubscription, |
| 35 | 42 | } from "../rpc/serverState"; |
| 36 | 43 | import { |
| 37 | − clearPromotedDraftThread, | |
| 38 | − clearPromotedDraftThreads, | |
| 44 | + markPromotedDraftThreadByRef, | |
| 45 | + markPromotedDraftThreadsByRef, | |
| 39 | 46 | useComposerDraftStore, |
| 40 | 47 | } from "../composerDraftStore"; |
| 41 | −import { selectProjects, selectThreadById, selectThreads, useStore } from "../store"; | |
| 48 | +import { | |
| 49 | + selectProjectsAcrossEnvironments, | |
| 50 | + selectThreadsAcrossEnvironments, | |
| 51 | + useStore, | |
| 52 | +} from "../store"; | |
| 42 | 53 | import { useUiStateStore } from "../uiStateStore"; |
| 43 | 54 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 44 | 55 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; |
| @@ -48,7 +59,15 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; · 3 unmodified lines | ||
| 48 | 59 | import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; |
| 49 | 60 | import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; |
| 50 | 61 | import { deriveReplayRetryDecision } from "../orchestrationRecovery"; |
| 51 | −import { getWsRpcClient } from "~/wsRpcClient"; | |
| 62 | +import { selectThreadByRef } from "../store"; | |
| 63 | +import { | |
| 64 | + bindPrimaryWsRpcClientEnvironment, | |
| 65 | + bindWsRpcClientEntryEnvironment, | |
| 66 | + getPrimaryWsRpcClientEntry, | |
| 67 | + listWsRpcClientEntries, | |
| 68 | + subscribeWsRpcClientRegistry, | |
| 69 | + type WsRpcClientEntry, | |
| 70 | +} from "~/wsRpcClient"; | |
| 52 | 71 | |
| 53 | 72 | export const Route = createRootRouteWithContext<{ |
| 54 | 73 | queryClient: QueryClient; |
| @@ -61,7 +80,7 @@ export const Route = createRootRouteWithContext<{ · 6 unmodified lines | ||
| 61 | 80 | }); |
| 62 | 81 | |
| 63 | 82 | function RootRouteView() { |
| 64 | − if (!readNativeApi()) { | |
| 83 | + if (!readLocalApi()) { | |
| 65 | 84 | return ( |
| 66 | 85 | <div className="flex h-screen flex-col bg-background text-foreground"> |
| 67 | 86 | <div className="flex flex-1 items-center justify-center"> |
| @@ -201,14 +220,24 @@ function coalesceOrchestrationUiEvents( · 133 unmodified lines | ||
| 201 | 220 | const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; |
| 202 | 221 | const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; |
| 203 | 222 | |
| 223 | +function useRegisteredWsRpcClientEntries(): ReadonlyArray<WsRpcClientEntry> { | |
| 224 | + const [, setRevision] = useState(0); | |
| 225 | + | |
| 226 | + useEffect(() => subscribeWsRpcClientRegistry(() => setRevision((value) => value + 1)), []); | |
| 227 | + | |
| 228 | + const entries = listWsRpcClientEntries(); | |
| 229 | + return entries.length > 0 ? entries : [getPrimaryWsRpcClientEntry()]; | |
| 230 | +} | |
| 231 | + | |
| 204 | 232 | function ServerStateBootstrap() { |
| 205 | − useEffect(() => startServerStateSync(getWsRpcClient().server), []); | |
| 233 | + useEffect(() => startServerStateSync(getPrimaryWsRpcClientEntry().client.server), []); | |
| 206 | 234 | |
| 207 | 235 | return null; |
| 208 | 236 | } |
| 209 | 237 | |
| 210 | 238 | function EventRouter() { |
| 211 | 239 | const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); |
| 240 | + const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); | |
| 212 | 241 | const syncServerReadModel = useStore((store) => store.syncServerReadModel); |
| 213 | 242 | const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); |
| 214 | 243 | const syncProjects = useUiStateStore((store) => store.syncProjects); |
| @@ -226,15 +255,22 @@ function EventRouter() { · 11 unmodified lines | ||
| 226 | 255 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 227 | 256 | const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); |
| 228 | 257 | const disposedRef = useRef(false); |
| 229 | − const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined); | |
| 258 | + const bootstrapFromSnapshotRef = useRef<(environmentId: EnvironmentId) => Promise<void>>( | |
| 259 | + async () => undefined, | |
| 260 | + ); | |
| 261 | + const schedulePendingDomainEventFlushRef = useRef<() => void>(() => undefined); | |
| 230 | 262 | const serverConfig = useServerConfig(); |
| 263 | + const clientEntries = useRegisteredWsRpcClientEntries(); | |
| 231 | 264 | |
| 232 | 265 | const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { |
| 233 | 266 | if (!payload) return; |
| 234 | 267 | |
| 268 | + bindPrimaryWsRpcClientEnvironment(payload.environment.environmentId); | |
| 269 | + setActiveEnvironmentId(payload.environment.environmentId); | |
| 270 | + schedulePendingDomainEventFlushRef.current(); | |
| 235 | 271 | migrateLocalSettingsToServer(); |
| 236 | 272 | void (async () => { |
| 237 | − await bootstrapFromSnapshotRef.current(); | |
| 273 | + await bootstrapFromSnapshotRef.current(payload.environment.environmentId); | |
| 238 | 274 | if (disposedRef.current) { |
| 239 | 275 | return; |
| 240 | 276 | } |
| @@ -242,7 +278,12 @@ function EventRouter() { · 1 unmodified line | ||
| 242 | 278 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { |
| 243 | 279 | return; |
| 244 | 280 | } |
| 245 | − setProjectExpanded(payload.bootstrapProjectId, true); | |
| 281 | + setProjectExpanded( | |
| 282 | + scopedProjectKey( | |
| 283 | + scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), | |
| 284 | + ), | |
| 285 | + true, | |
| 286 | + ); | |
| 246 | 287 | |
| 247 | 288 | if (readPathname() !== "/") { |
| 248 | 289 | return; |
| @@ -251,8 +292,11 @@ function EventRouter() { · 2 unmodified lines | ||
| 251 | 292 | return; |
| 252 | 293 | } |
| 253 | 294 | await navigate({ |
| 254 | − to: "/$threadId", | |
| 255 | − params: { threadId: payload.bootstrapThreadId }, | |
| 295 | + to: "/$environmentId/$threadId", | |
| 296 | + params: { | |
| 297 | + environmentId: payload.environment.environmentId, | |
| 298 | + threadId: payload.bootstrapThreadId, | |
| 299 | + }, | |
| 256 | 300 | replace: true, |
| 257 | 301 | }); |
| 258 | 302 | handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; |
| @@ -289,7 +333,7 @@ function EventRouter() { · 30 unmodified lines | ||
| 289 | 333 | actionProps: { |
| 290 | 334 | children: "Open keybindings.json", |
| 291 | 335 | onClick: () => { |
| 292 | − const api = readNativeApi(); | |
| 336 | + const api = readLocalApi(); | |
| 293 | 337 | if (!api) { |
| 294 | 338 | return; |
| 295 | 339 | } |
| @@ -317,40 +361,49 @@ function EventRouter() { · 21 unmodified lines | ||
| 317 | 361 | ); |
| 318 | 362 | |
| 319 | 363 | useEffect(() => { |
| 320 | − const api = readNativeApi(); | |
| 321 | − if (!api) return; | |
| 364 | + if (!serverConfig) { | |
| 365 | + return; | |
| 366 | + } | |
| 367 | + | |
| 368 | + bindPrimaryWsRpcClientEnvironment(serverConfig.environment.environmentId); | |
| 369 | + setActiveEnvironmentId(serverConfig.environment.environmentId); | |
| 370 | + schedulePendingDomainEventFlushRef.current(); | |
| 371 | + }, [serverConfig, setActiveEnvironmentId]); | |
| 372 | + | |
| 373 | + useEffect(() => { | |
| 322 | 374 | let disposed = false; |
| 323 | 375 | disposedRef.current = false; |
| 324 | − const recovery = createOrchestrationRecoveryCoordinator(); | |
| 325 | − let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null; | |
| 326 | 376 | let needsProviderInvalidation = false; |
| 327 | − const pendingDomainEvents: OrchestrationEvent[] = []; | |
| 328 | − let flushPendingDomainEventsScheduled = false; | |
| 377 | + const primaryClientKey = getPrimaryWsRpcClientEntry().key; | |
| 329 | 378 | |
| 330 | 379 | const reconcileSnapshotDerivedState = () => { |
| 331 | 380 | const storeState = useStore.getState(); |
| 332 | − const threads = selectThreads(storeState); | |
| 333 | − const projects = selectProjects(storeState); | |
| 334 | − syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); | |
| 381 | + const threads = selectThreadsAcrossEnvironments(storeState); | |
| 382 | + const projects = selectProjectsAcrossEnvironments(storeState); | |
| 383 | + syncProjects( | |
| 384 | + projects.map((project) => ({ | |
| 385 | + key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 386 | + cwd: project.cwd, | |
| 387 | + })), | |
| 388 | + ); | |
| 335 | 389 | syncThreads( |
| 336 | 390 | threads.map((thread) => ({ |
| 337 | − id: thread.id, | |
| 391 | + key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 338 | 392 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, |
| 339 | 393 | })), |
| 340 | 394 | ); |
| 341 | − clearPromotedDraftThreads(threads.map((thread) => thread.id)); | |
| 342 | − const draftThreadIds = Object.keys( | |
| 343 | − useComposerDraftStore.getState().draftThreadsByThreadId, | |
| 344 | − ) as ThreadId[]; | |
| 345 | − const activeThreadIds = collectActiveTerminalThreadIds({ | |
| 395 | + markPromotedDraftThreadsByRef( | |
| 396 | + threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), | |
| 397 | + ); | |
| 398 | + const activeThreadKeys = collectActiveTerminalThreadIds({ | |
| 346 | 399 | snapshotThreads: threads.map((thread) => ({ |
| 347 | − id: thread.id, | |
| 400 | + key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 348 | 401 | deletedAt: null, |
| 349 | 402 | archivedAt: thread.archivedAt, |
| 350 | 403 | })), |
| 351 | − draftThreadIds, | |
| 404 | + draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(), | |
| 352 | 405 | }); |
| 353 | − removeOrphanedTerminalStates(activeThreadIds); | |
| 406 | + removeOrphanedTerminalStates(activeThreadKeys); | |
| 354 | 407 | }; |
| 355 | 408 | |
| 356 | 409 | const queryInvalidationThrottler = new Throttler( |
| @@ -371,7 +424,11 @@ function EventRouter() { · 14 unmodified lines | ||
| 371 | 424 | }, |
| 372 | 425 | ); |
| 373 | 426 | |
| 374 | − const applyEventBatch = (events: ReadonlyArray<OrchestrationEvent>) => { | |
| 427 | + const applyEventBatch = ( | |
| 428 | + events: ReadonlyArray<OrchestrationEvent>, | |
| 429 | + environmentId: EnvironmentId, | |
| 430 | + recovery: ReturnType<typeof createOrchestrationRecoveryCoordinator>, | |
| 431 | + ) => { | |
| 375 | 432 | const nextEvents = recovery.markEventBatchApplied(events); |
| 376 | 433 | if (nextEvents.length === 0) { |
| 377 | 434 | return; |
| @@ -391,189 +448,284 @@ function EventRouter() { · 13 unmodified lines | ||
| 391 | 448 | void queryInvalidationThrottler.maybeExecute(); |
| 392 | 449 | } |
| 393 | 450 | |
| 394 | − applyOrchestrationEvents(uiEvents); | |
| 451 | + applyOrchestrationEvents(uiEvents, environmentId); | |
| 395 | 452 | if (needsProjectUiSync) { |
| 396 | − const projects = selectProjects(useStore.getState()); | |
| 397 | − syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); | |
| 453 | + const projects = selectProjectsAcrossEnvironments(useStore.getState()); | |
| 454 | + syncProjects( | |
| 455 | + projects.map((project) => ({ | |
| 456 | + key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 457 | + cwd: project.cwd, | |
| 458 | + })), | |
| 459 | + ); | |
| 398 | 460 | } |
| 399 | 461 | const needsThreadUiSync = nextEvents.some( |
| 400 | 462 | (event) => event.type === "thread.created" || event.type === "thread.deleted", |
| 401 | 463 | ); |
| 402 | 464 | if (needsThreadUiSync) { |
| 403 | − const threads = selectThreads(useStore.getState()); | |
| 465 | + const threads = selectThreadsAcrossEnvironments(useStore.getState()); | |
| 404 | 466 | syncThreads( |
| 405 | 467 | threads.map((thread) => ({ |
| 406 | − id: thread.id, | |
| 468 | + key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 407 | 469 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, |
| 408 | 470 | })), |
| 409 | 471 | ); |
| 410 | 472 | } |
| 411 | 473 | const draftStore = useComposerDraftStore.getState(); |
| 412 | − for (const threadId of batchEffects.clearPromotedDraftThreadIds) { | |
| 413 | − clearPromotedDraftThread(threadId); | |
| 474 | + for (const threadId of batchEffects.promoteDraftThreadIds) { | |
| 475 | + markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId)); | |
| 414 | 476 | } |
| 415 | 477 | for (const threadId of batchEffects.clearDeletedThreadIds) { |
| 416 | − draftStore.clearDraftThread(threadId); | |
| 417 | − clearThreadUi(threadId); | |
| 478 | + draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId)); | |
| 479 | + clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); | |
| 418 | 480 | } |
| 419 | 481 | for (const threadId of batchEffects.removeTerminalStateThreadIds) { |
| 420 | − removeTerminalState(threadId); | |
| 482 | + removeTerminalState(scopeThreadRef(environmentId, threadId)); | |
| 421 | 483 | } |
| 422 | 484 | }; |
| 423 | − const flushPendingDomainEvents = () => { | |
| 424 | − flushPendingDomainEventsScheduled = false; | |
| 425 | − if (disposed || pendingDomainEvents.length === 0) { | |
| 426 | − return; | |
| 427 | − } | |
| 485 | + const clientContexts = clientEntries.map((entry) => { | |
| 486 | + const recovery = createOrchestrationRecoveryCoordinator(); | |
| 487 | + let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null; | |
| 488 | + const pendingDomainEvents: OrchestrationEvent[] = []; | |
| 489 | + let flushPendingDomainEventsScheduled = false; | |
| 490 | + let boundEnvironmentId = entry.environmentId; | |
| 491 | + | |
| 492 | + const bindEnvironmentId = (environmentId: EnvironmentId) => { | |
| 493 | + if (boundEnvironmentId === environmentId) { | |
| 494 | + return; | |
| 495 | + } | |
| 496 | + boundEnvironmentId = environmentId; | |
| 497 | + bindWsRpcClientEntryEnvironment(entry.key, environmentId); | |
| 498 | + schedulePendingDomainEventFlush(); | |
| 499 | + }; | |
| 428 | 500 | |
| 429 | − const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); | |
| 430 | − applyEventBatch(events); | |
| 431 | − }; | |
| 432 | − const schedulePendingDomainEventFlush = () => { | |
| 433 | − if (flushPendingDomainEventsScheduled) { | |
| 434 | − return; | |
| 435 | − } | |
| 501 | + const flushPendingDomainEvents = () => { | |
| 502 | + flushPendingDomainEventsScheduled = false; | |
| 503 | + if (disposed || pendingDomainEvents.length === 0 || boundEnvironmentId === null) { | |
| 504 | + return; | |
| 505 | + } | |
| 436 | 506 | |
| 437 | − flushPendingDomainEventsScheduled = true; | |
| 438 | − queueMicrotask(flushPendingDomainEvents); | |
| 439 | − }; | |
| 507 | + const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); | |
| 508 | + applyEventBatch(events, boundEnvironmentId, recovery); | |
| 509 | + }; | |
| 440 | 510 | |
| 441 | − const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => { | |
| 442 | − if (!recovery.beginReplayRecovery(reason)) { | |
| 443 | − return; | |
| 444 | − } | |
| 511 | + const schedulePendingDomainEventFlush = () => { | |
| 512 | + if (flushPendingDomainEventsScheduled) { | |
| 513 | + return; | |
| 514 | + } | |
| 445 | 515 | |
| 446 | − const fromSequenceExclusive = recovery.getState().latestSequence; | |
| 447 | − try { | |
| 448 | − const events = await api.orchestration.replayEvents(fromSequenceExclusive); | |
| 449 | − if (!disposed) { | |
| 450 | − applyEventBatch(events); | |
| 516 | + flushPendingDomainEventsScheduled = true; | |
| 517 | + queueMicrotask(flushPendingDomainEvents); | |
| 518 | + }; | |
| 519 | + | |
| 520 | + const runSnapshotRecovery = async ( | |
| 521 | + reason: "bootstrap" | "replay-failed", | |
| 522 | + environmentId: EnvironmentId, | |
| 523 | + ): Promise<void> => { | |
| 524 | + const started = recovery.beginSnapshotRecovery(reason); | |
| 525 | + if (import.meta.env.MODE !== "test") { | |
| 526 | + const state = recovery.getState(); | |
| 527 | + console.info("[orchestration-recovery]", "Snapshot recovery requested.", { | |
| 528 | + reason, | |
| 529 | + clientKey: entry.key, | |
| 530 | + environmentId, | |
| 531 | + skipped: !started, | |
| 532 | + ...(started | |
| 533 | + ? {} | |
| 534 | + : { | |
| 535 | + blockedBy: state.inFlight?.kind ?? null, | |
| 536 | + blockedByReason: state.inFlight?.reason ?? null, | |
| 537 | + }), | |
| 538 | + state, | |
| 539 | + }); | |
| 540 | + } | |
| 541 | + if (!started) { | |
| 542 | + return; | |
| 451 | 543 | } |
| 452 | − } catch { | |
| 453 | − replayRetryTracker = null; | |
| 454 | − recovery.failReplayRecovery(); | |
| 455 | − void fallbackToSnapshotRecovery(); | |
| 456 | − return; | |
| 457 | − } | |
| 458 | 544 | |
| 459 | − if (!disposed) { | |
| 460 | − const replayCompletion = recovery.completeReplayRecovery(); | |
| 461 | − const retryDecision = deriveReplayRetryDecision({ | |
| 462 | − previousTracker: replayRetryTracker, | |
| 463 | − completion: replayCompletion, | |
| 464 | − recoveryState: recovery.getState(), | |
| 465 | − baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, | |
| 466 | − maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, | |
| 467 | − }); | |
| 468 | − replayRetryTracker = retryDecision.tracker; | |
| 545 | + try { | |
| 546 | + const snapshot = await entry.client.orchestration.getSnapshot(); | |
| 547 | + if (!disposed) { | |
| 548 | + bindEnvironmentId(environmentId); | |
| 549 | + syncServerReadModel(snapshot, environmentId); | |
| 550 | + reconcileSnapshotDerivedState(); | |
| 551 | + if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { | |
| 552 | + void runReplayRecovery("sequence-gap"); | |
| 553 | + } | |
| 554 | + } | |
| 555 | + } catch { | |
| 556 | + recovery.failSnapshotRecovery(); | |
| 557 | + } | |
| 558 | + }; | |
| 469 | 559 | |
| 470 | − if (retryDecision.shouldRetry) { | |
| 471 | − if (retryDecision.delayMs > 0) { | |
| 472 | − await new Promise<void>((resolve) => { | |
| 473 | − setTimeout(resolve, retryDecision.delayMs); | |
| 474 | − }); | |
| 475 | − if (disposed) { | |
| 560 | + const fallbackToSnapshotRecovery = async (): Promise<void> => { | |
| 561 | + if (boundEnvironmentId === null) { | |
| 562 | + return; | |
| 563 | + } | |
| 564 | + await runSnapshotRecovery("replay-failed", boundEnvironmentId); | |
| 565 | + }; | |
| 566 | + | |
| 567 | + const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => { | |
| 568 | + if (!recovery.beginReplayRecovery(reason)) { | |
| 569 | + return; | |
| 570 | + } | |
| 571 | + | |
| 572 | + const fromSequenceExclusive = recovery.getState().latestSequence; | |
| 573 | + try { | |
| 574 | + const events = await entry.client.orchestration.replayEvents({ fromSequenceExclusive }); | |
| 575 | + if (!disposed) { | |
| 576 | + if (boundEnvironmentId === null) { | |
| 577 | + replayRetryTracker = null; | |
| 578 | + recovery.failReplayRecovery(); | |
| 476 | 579 | return; |
| 477 | 580 | } |
| 581 | + applyEventBatch(events, boundEnvironmentId, recovery); | |
| 478 | 582 | } |
| 479 | − void runReplayRecovery(reason); | |
| 480 | − } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { | |
| 481 | − console.warn( | |
| 482 | − "[orchestration-recovery]", | |
| 483 | − "Stopping replay recovery after no-progress retries.", | |
| 484 | − { | |
| 485 | − state: recovery.getState(), | |
| 486 | − }, | |
| 487 | − ); | |
| 583 | + } catch { | |
| 584 | + replayRetryTracker = null; | |
| 585 | + recovery.failReplayRecovery(); | |
| 586 | + void fallbackToSnapshotRecovery(); | |
| 587 | + return; | |
| 488 | 588 | } |
| 489 | − } | |
| 490 | − }; | |
| 491 | − | |
| 492 | − const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise<void> => { | |
| 493 | − const started = recovery.beginSnapshotRecovery(reason); | |
| 494 | − if (import.meta.env.MODE !== "test") { | |
| 495 | − const state = recovery.getState(); | |
| 496 | − console.info("[orchestration-recovery]", "Snapshot recovery requested.", { | |
| 497 | − reason, | |
| 498 | − skipped: !started, | |
| 499 | − ...(started | |
| 500 | − ? {} | |
| 501 | − : { | |
| 502 | − blockedBy: state.inFlight?.kind ?? null, | |
| 503 | − blockedByReason: state.inFlight?.reason ?? null, | |
| 504 | − }), | |
| 505 | − state, | |
| 506 | − }); | |
| 507 | − } | |
| 508 | − if (!started) { | |
| 509 | − return; | |
| 510 | − } | |
| 511 | 589 | |
| 512 | − try { | |
| 513 | − const snapshot = await api.orchestration.getSnapshot(); | |
| 514 | 590 | if (!disposed) { |
| 515 | − syncServerReadModel(snapshot); | |
| 516 | − reconcileSnapshotDerivedState(); | |
| 517 | − if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { | |
| 518 | − void runReplayRecovery("sequence-gap"); | |
| 591 | + const replayCompletion = recovery.completeReplayRecovery(); | |
| 592 | + const retryDecision = deriveReplayRetryDecision({ | |
| 593 | + previousTracker: replayRetryTracker, | |
| 594 | + completion: replayCompletion, | |
| 595 | + recoveryState: recovery.getState(), | |
| 596 | + baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, | |
| 597 | + maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, | |
| 598 | + }); | |
| 599 | + replayRetryTracker = retryDecision.tracker; | |
| 600 | + | |
| 601 | + if (retryDecision.shouldRetry) { | |
| 602 | + if (retryDecision.delayMs > 0) { | |
| 603 | + await new Promise<void>((resolve) => { | |
| 604 | + setTimeout(resolve, retryDecision.delayMs); | |
| 605 | + }); | |
| 606 | + if (disposed) { | |
| 607 | + return; | |
| 608 | + } | |
| 609 | + } | |
| 610 | + void runReplayRecovery(reason); | |
| 611 | + } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { | |
| 612 | + console.warn( | |
| 613 | + "[orchestration-recovery]", | |
| 614 | + "Stopping replay recovery after no-progress retries.", | |
| 615 | + { | |
| 616 | + clientKey: entry.key, | |
| 617 | + environmentId: boundEnvironmentId, | |
| 618 | + state: recovery.getState(), | |
| 619 | + }, | |
| 620 | + ); | |
| 519 | 621 | } |
| 520 | 622 | } |
| 521 | − } catch { | |
| 522 | − // Keep prior state and wait for welcome or a later replay attempt. | |
| 523 | − recovery.failSnapshotRecovery(); | |
| 524 | − } | |
| 525 | − }; | |
| 526 | − | |
| 527 | − const bootstrapFromSnapshot = async (): Promise<void> => { | |
| 528 | − await runSnapshotRecovery("bootstrap"); | |
| 529 | − }; | |
| 530 | − bootstrapFromSnapshotRef.current = bootstrapFromSnapshot; | |
| 623 | + }; | |
| 531 | 624 | |
| 532 | − const fallbackToSnapshotRecovery = async (): Promise<void> => { | |
| 533 | − await runSnapshotRecovery("replay-failed"); | |
| 534 | − }; | |
| 535 | − const unsubDomainEvent = api.orchestration.onDomainEvent( | |
| 536 | − (event) => { | |
| 537 | − const action = recovery.classifyDomainEvent(event.sequence); | |
| 538 | − if (action === "apply") { | |
| 539 | − pendingDomainEvents.push(event); | |
| 540 | − schedulePendingDomainEventFlush(); | |
| 541 | − return; | |
| 625 | + const unsubLifecycle = entry.client.server.subscribeLifecycle((event) => { | |
| 626 | + if (event.type === "welcome") { | |
| 627 | + bindEnvironmentId(event.payload.environment.environmentId); | |
| 542 | 628 | } |
| 543 | − if (action === "recover") { | |
| 544 | − flushPendingDomainEvents(); | |
| 545 | − void runReplayRecovery("sequence-gap"); | |
| 629 | + }); | |
| 630 | + const unsubConfig = entry.client.server.subscribeConfig((event) => { | |
| 631 | + if (event.type === "snapshot") { | |
| 632 | + bindEnvironmentId(event.config.environment.environmentId); | |
| 546 | 633 | } |
| 547 | − }, | |
| 548 | − { | |
| 549 | − onResubscribe: () => { | |
| 550 | − if (disposed) { | |
| 634 | + }); | |
| 635 | + if (boundEnvironmentId === null) { | |
| 636 | + void entry.client.server | |
| 637 | + .getConfig() | |
| 638 | + .then((config) => { | |
| 639 | + if (!disposed) { | |
| 640 | + bindEnvironmentId(config.environment.environmentId); | |
| 641 | + } | |
| 642 | + }) | |
| 643 | + .catch(() => undefined); | |
| 644 | + } | |
| 645 | + const unsubDomainEvent = entry.client.orchestration.onDomainEvent( | |
| 646 | + (event) => { | |
| 647 | + const action = recovery.classifyDomainEvent(event.sequence); | |
| 648 | + if (action === "apply") { | |
| 649 | + pendingDomainEvents.push(event); | |
| 650 | + schedulePendingDomainEventFlush(); | |
| 551 | 651 | return; |
| 552 | 652 | } |
| 553 | − flushPendingDomainEvents(); | |
| 554 | − void runReplayRecovery("resubscribe"); | |
| 653 | + if (action === "recover") { | |
| 654 | + flushPendingDomainEvents(); | |
| 655 | + void runReplayRecovery("sequence-gap"); | |
| 656 | + } | |
| 555 | 657 | }, |
| 556 | − }, | |
| 557 | − ); | |
| 558 | − const unsubTerminalEvent = api.terminal.onEvent((event) => { | |
| 559 | − const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); | |
| 560 | − if (thread && thread.archivedAt !== null) { | |
| 658 | + { | |
| 659 | + onResubscribe: () => { | |
| 660 | + if (disposed) { | |
| 661 | + return; | |
| 662 | + } | |
| 663 | + flushPendingDomainEvents(); | |
| 664 | + void runReplayRecovery("resubscribe"); | |
| 665 | + }, | |
| 666 | + }, | |
| 667 | + ); | |
| 668 | + const unsubTerminalEvent = entry.client.terminal.onEvent((event) => { | |
| 669 | + if (boundEnvironmentId === null) { | |
| 670 | + return; | |
| 671 | + } | |
| 672 | + | |
| 673 | + const threadRef = scopeThreadRef(boundEnvironmentId, ThreadId.makeUnsafe(event.threadId)); | |
| 674 | + const thread = selectThreadByRef(useStore.getState(), threadRef); | |
| 675 | + if (!thread || thread.archivedAt !== null) { | |
| 676 | + return; | |
| 677 | + } | |
| 678 | + applyTerminalEvent(threadRef, event); | |
| 679 | + }); | |
| 680 | + | |
| 681 | + return { | |
| 682 | + key: entry.key, | |
| 683 | + bindEnvironmentId, | |
| 684 | + flushPendingDomainEvents, | |
| 685 | + schedulePendingDomainEventFlush, | |
| 686 | + runSnapshotRecovery, | |
| 687 | + cleanup: () => { | |
| 688 | + flushPendingDomainEventsScheduled = false; | |
| 689 | + pendingDomainEvents.length = 0; | |
| 690 | + unsubDomainEvent(); | |
| 691 | + unsubTerminalEvent(); | |
| 692 | + unsubLifecycle(); | |
| 693 | + unsubConfig(); | |
| 694 | + }, | |
| 695 | + }; | |
| 696 | + }); | |
| 697 | + | |
| 698 | + schedulePendingDomainEventFlushRef.current = () => { | |
| 699 | + for (const context of clientContexts) { | |
| 700 | + context.schedulePendingDomainEventFlush(); | |
| 701 | + } | |
| 702 | + }; | |
| 703 | + | |
| 704 | + const primaryClientContext = | |
| 705 | + clientContexts.find((context) => context.key === primaryClientKey) ?? | |
| 706 | + clientContexts[0] ?? | |
| 707 | + null; | |
| 708 | + bootstrapFromSnapshotRef.current = async (environmentId: EnvironmentId) => { | |
| 709 | + if (!primaryClientContext) { | |
| 561 | 710 | return; |
| 562 | 711 | } |
| 563 | − applyTerminalEvent(event); | |
| 564 | − }); | |
| 712 | + primaryClientContext.bindEnvironmentId(environmentId); | |
| 713 | + await primaryClientContext.runSnapshotRecovery("bootstrap", environmentId); | |
| 714 | + }; | |
| 715 | + | |
| 565 | 716 | return () => { |
| 566 | 717 | disposed = true; |
| 567 | 718 | disposedRef.current = true; |
| 568 | 719 | needsProviderInvalidation = false; |
| 569 | − flushPendingDomainEventsScheduled = false; | |
| 570 | − pendingDomainEvents.length = 0; | |
| 720 | + schedulePendingDomainEventFlushRef.current = () => undefined; | |
| 571 | 721 | queryInvalidationThrottler.cancel(); |
| 572 | − unsubDomainEvent(); | |
| 573 | − unsubTerminalEvent(); | |
| 722 | + for (const context of clientContexts) { | |
| 723 | + context.cleanup(); | |
| 724 | + } | |
| 574 | 725 | }; |
| 575 | 726 | }, [ |
| 576 | 727 | applyOrchestrationEvents, |
| 728 | + clientEntries, | |
| 577 | 729 | navigate, |
| 578 | 730 | queryClient, |
| 579 | 731 | removeTerminalState, |
| @@ -581,6 +733,7 @@ function EventRouter() { · 1 unmodified line | ||
| 581 | 733 | applyTerminalEvent, |
| 582 | 734 | clearThreadUi, |
| 583 | 735 | setProjectExpanded, |
| 736 | + setActiveEnvironmentId, | |
| 584 | 737 | syncProjects, |
| 585 | 738 | syncServerReadModel, |
| 586 | 739 | syncThreads, |
| @@ -1,8 +1,8 @@ | ||
| 1 | −import { ThreadId } from "@t3tools/contracts"; | |
| 2 | 1 | import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; |
| 3 | −import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react"; | |
| 2 | +import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; | |
| 4 | 3 | |
| 5 | 4 | import ChatView from "../components/ChatView"; |
| 5 | +import { threadHasStarted } from "../components/ChatView.logic"; | |
| 6 | 6 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; |
| 7 | 7 | import { |
| 8 | 8 | DiffPanelHeaderSkeleton, |
| @@ -10,14 +10,16 @@ import { · 1 unmodified line | ||
| 10 | 10 | DiffPanelShell, |
| 11 | 11 | type DiffPanelMode, |
| 12 | 12 | } from "../components/DiffPanelShell"; |
| 13 | −import { useComposerDraftStore } from "../composerDraftStore"; | |
| 13 | +import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; | |
| 14 | 14 | import { |
| 15 | 15 | type DiffRouteSearch, |
| 16 | 16 | parseDiffRouteSearch, |
| 17 | 17 | stripDiffSearchParams, |
| 18 | 18 | } from "../diffRouteSearch"; |
| 19 | 19 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 20 | −import { useStore } from "../store"; | |
| 20 | +import { selectEnvironmentState, selectThreadByRef, useStore } from "../store"; | |
| 21 | +import { createThreadSelectorByRef } from "../storeSelectors"; | |
| 22 | +import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; | |
| 21 | 23 | import { Sheet, SheetPopup } from "../components/ui/sheet"; |
| 22 | 24 | import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; |
| 23 | 25 | |
| @@ -161,39 +163,60 @@ const DiffPanelInlineSidebar = (props: { · 137 unmodified lines | ||
| 161 | 163 | }; |
| 162 | 164 | |
| 163 | 165 | function ChatThreadRouteView() { |
| 164 | − const bootstrapComplete = useStore((store) => store.bootstrapComplete); | |
| 165 | 166 | const navigate = useNavigate(); |
| 166 | − const threadId = Route.useParams({ | |
| 167 | − select: (params) => ThreadId.makeUnsafe(params.threadId), | |
| 167 | + const threadRef = Route.useParams({ | |
| 168 | + select: (params) => resolveThreadRouteRef(params), | |
| 168 | 169 | }); |
| 169 | 170 | const search = Route.useSearch(); |
| 170 | − const threadExists = useStore((store) => store.threadShellById[threadId] !== undefined); | |
| 171 | + const bootstrapComplete = useStore( | |
| 172 | + (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, | |
| 173 | + ); | |
| 174 | + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); | |
| 175 | + const threadExists = useStore((store) => selectThreadByRef(store, threadRef) !== undefined); | |
| 176 | + const environmentHasServerThreads = useStore( | |
| 177 | + (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, | |
| 178 | + ); | |
| 171 | 179 | const draftThreadExists = useComposerDraftStore((store) => |
| 172 | − Object.hasOwn(store.draftThreadsByThreadId, threadId), | |
| 180 | + threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, | |
| 173 | 181 | ); |
| 182 | + const draftThread = useComposerDraftStore((store) => | |
| 183 | + threadRef ? store.getDraftThreadByRef(threadRef) : null, | |
| 184 | + ); | |
| 185 | + const environmentHasDraftThreads = useComposerDraftStore((store) => { | |
| 186 | + if (!threadRef) { | |
| 187 | + return false; | |
| 188 | + } | |
| 189 | + return store.hasDraftThreadsInEnvironment(threadRef.environmentId); | |
| 190 | + }); | |
| 174 | 191 | const routeThreadExists = threadExists || draftThreadExists; |
| 192 | + const serverThreadStarted = threadHasStarted(serverThread); | |
| 193 | + const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; | |
| 175 | 194 | const diffOpen = search.diff === "1"; |
| 176 | 195 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); |
| 177 | − // TanStack Router keeps active route components mounted across param-only navigations | |
| 178 | − // unless remountDeps are configured, so this stays warm across thread switches. | |
| 179 | 196 | const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen); |
| 180 | 197 | const closeDiff = useCallback(() => { |
| 198 | + if (!threadRef) { | |
| 199 | + return; | |
| 200 | + } | |
| 181 | 201 | void navigate({ |
| 182 | − to: "/$threadId", | |
| 183 | − params: { threadId }, | |
| 202 | + to: "/$environmentId/$threadId", | |
| 203 | + params: buildThreadRouteParams(threadRef), | |
| 184 | 204 | search: { diff: undefined }, |
| 185 | 205 | }); |
| 186 | − }, [navigate, threadId]); | |
| 206 | + }, [navigate, threadRef]); | |
| 187 | 207 | const openDiff = useCallback(() => { |
| 208 | + if (!threadRef) { | |
| 209 | + return; | |
| 210 | + } | |
| 188 | 211 | void navigate({ |
| 189 | − to: "/$threadId", | |
| 190 | − params: { threadId }, | |
| 212 | + to: "/$environmentId/$threadId", | |
| 213 | + params: buildThreadRouteParams(threadRef), | |
| 191 | 214 | search: (previous) => { |
| 192 | 215 | const rest = stripDiffSearchParams(previous); |
| 193 | 216 | return { ...rest, diff: "1" }; |
| 194 | 217 | }, |
| 195 | 218 | }); |
| 196 | − }, [navigate, threadId]); | |
| 219 | + }, [navigate, threadRef]); | |
| 197 | 220 | |
| 198 | 221 | useEffect(() => { |
| 199 | 222 | if (diffOpen) { |
| @@ -202,17 +225,23 @@ function ChatThreadRouteView() { · 2 unmodified lines | ||
| 202 | 225 | }, [diffOpen]); |
| 203 | 226 | |
| 204 | 227 | useEffect(() => { |
| 205 | − if (!bootstrapComplete) { | |
| 228 | + if (!threadRef || !bootstrapComplete) { | |
| 206 | 229 | return; |
| 207 | 230 | } |
| 208 | 231 | |
| 209 | − if (!routeThreadExists) { | |
| 232 | + if (!routeThreadExists && environmentHasAnyThreads) { | |
| 210 | 233 | void navigate({ to: "/", replace: true }); |
| 234 | + } | |
| 235 | + }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); | |
| 236 | + | |
| 237 | + useEffect(() => { | |
| 238 | + if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) { | |
| 211 | 239 | return; |
| 212 | 240 | } |
| 213 | − }, [bootstrapComplete, navigate, routeThreadExists, threadId]); | |
| 241 | + finalizePromotedDraftThreadByRef(threadRef); | |
| 242 | + }, [draftThread?.promotedTo, serverThreadStarted, threadRef]); | |
| 214 | 243 | |
| 215 | − if (!bootstrapComplete || !routeThreadExists) { | |
| 244 | + if (!threadRef || !bootstrapComplete || !routeThreadExists) { | |
| 216 | 245 | return null; |
| 217 | 246 | } |
| 218 | 247 | |
| @@ -222,7 +251,11 @@ function ChatThreadRouteView() { · 3 unmodified lines | ||
| 222 | 251 | return ( |
| 223 | 252 | <> |
| 224 | 253 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> |
| 225 | − <ChatView threadId={threadId} /> | |
| 254 | + <ChatView | |
| 255 | + environmentId={threadRef.environmentId} | |
| 256 | + threadId={threadRef.threadId} | |
| 257 | + routeKind="server" | |
| 258 | + /> | |
| 226 | 259 | </SidebarInset> |
| 227 | 260 | <DiffPanelInlineSidebar |
| 228 | 261 | diffOpen={diffOpen} |
| @@ -237,7 +270,11 @@ function ChatThreadRouteView() { · 8 unmodified lines | ||
| 237 | 270 | return ( |
| 238 | 271 | <> |
| 239 | 272 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> |
| 240 | − <ChatView threadId={threadId} /> | |
| 273 | + <ChatView | |
| 274 | + environmentId={threadRef.environmentId} | |
| 275 | + threadId={threadRef.threadId} | |
| 276 | + routeKind="server" | |
| 277 | + /> | |
| 241 | 278 | </SidebarInset> |
| 242 | 279 | <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}> |
| 243 | 280 | {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null} |
| @@ -246,7 +283,7 @@ function ChatThreadRouteView() { · 2 unmodified lines | ||
| 246 | 283 | ); |
| 247 | 284 | } |
| 248 | 285 | |
| 249 | −export const Route = createFileRoute("/_chat/$threadId")({ | |
| 286 | +export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ | |
| 250 | 287 | validateSearch: (search) => parseDiffRouteSearch(search), |
| 251 | 288 | search: { |
| 252 | 289 | middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])], |
| @@ -0,0 +1,86 @@ | ||
| 1 | +import { createFileRoute, useNavigate } from "@tanstack/react-router"; | |
| 2 | +import { useEffect, useMemo } from "react"; | |
| 3 | +import ChatView from "../components/ChatView"; | |
| 4 | +import { threadHasStarted } from "../components/ChatView.logic"; | |
| 5 | +import { useComposerDraftStore, DraftId } from "../composerDraftStore"; | |
| 6 | +import { SidebarInset } from "../components/ui/sidebar"; | |
| 7 | +import { createThreadSelectorAcrossEnvironments } from "../storeSelectors"; | |
| 8 | +import { useStore } from "../store"; | |
| 9 | +import { buildThreadRouteParams } from "../threadRoutes"; | |
| 10 | + | |
| 11 | +function DraftChatThreadRouteView() { | |
| 12 | + const navigate = useNavigate(); | |
| 13 | + const { draftId: rawDraftId } = Route.useParams(); | |
| 14 | + const draftId = DraftId.makeUnsafe(rawDraftId); | |
| 15 | + const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId)); | |
| 16 | + const serverThread = useStore( | |
| 17 | + useMemo( | |
| 18 | + () => createThreadSelectorAcrossEnvironments(draftSession?.threadId ?? null), | |
| 19 | + [draftSession?.threadId], | |
| 20 | + ), | |
| 21 | + ); | |
| 22 | + const serverThreadStarted = threadHasStarted(serverThread); | |
| 23 | + const canonicalThreadRef = useMemo( | |
| 24 | + () => | |
| 25 | + draftSession?.promotedTo | |
| 26 | + ? serverThreadStarted | |
| 27 | + ? draftSession.promotedTo | |
| 28 | + : null | |
| 29 | + : serverThread | |
| 30 | + ? { | |
| 31 | + environmentId: serverThread.environmentId, | |
| 32 | + threadId: serverThread.id, | |
| 33 | + } | |
| 34 | + : null, | |
| 35 | + [draftSession?.promotedTo, serverThread, serverThreadStarted], | |
| 36 | + ); | |
| 37 | + | |
| 38 | + useEffect(() => { | |
| 39 | + if (!canonicalThreadRef) { | |
| 40 | + return; | |
| 41 | + } | |
| 42 | + void navigate({ | |
| 43 | + to: "/$environmentId/$threadId", | |
| 44 | + params: buildThreadRouteParams(canonicalThreadRef), | |
| 45 | + replace: true, | |
| 46 | + }); | |
| 47 | + }, [canonicalThreadRef, navigate]); | |
| 48 | + | |
| 49 | + useEffect(() => { | |
| 50 | + if (draftSession || canonicalThreadRef) { | |
| 51 | + return; | |
| 52 | + } | |
| 53 | + void navigate({ to: "/", replace: true }); | |
| 54 | + }, [canonicalThreadRef, draftSession, navigate]); | |
| 55 | + | |
| 56 | + if (canonicalThreadRef) { | |
| 57 | + return ( | |
| 58 | + <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | |
| 59 | + <ChatView | |
| 60 | + environmentId={canonicalThreadRef.environmentId} | |
| 61 | + threadId={canonicalThreadRef.threadId} | |
| 62 | + routeKind="server" | |
| 63 | + /> | |
| 64 | + </SidebarInset> | |
| 65 | + ); | |
| 66 | + } | |
| 67 | + | |
| 68 | + if (!draftSession) { | |
| 69 | + return null; | |
| 70 | + } | |
| 71 | + | |
| 72 | + return ( | |
| 73 | + <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | |
| 74 | + <ChatView | |
| 75 | + draftId={draftId} | |
| 76 | + environmentId={draftSession.environmentId} | |
| 77 | + threadId={draftSession.threadId} | |
| 78 | + routeKind="draft" | |
| 79 | + /> | |
| 80 | + </SidebarInset> | |
| 81 | + ); | |
| 82 | +} | |
| 83 | + | |
| 84 | +export const Route = createFileRoute("/_chat/draft/$draftId")({ | |
| 85 | + component: DraftChatThreadRouteView, | |
| 86 | +}); | |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { scopeProjectRef } from "@t3tools/client-runtime"; | |
| 1 | 2 | import { Outlet, createFileRoute } from "@tanstack/react-router"; |
| 2 | 3 | import { useEffect } from "react"; |
| 3 | 4 | |
| @@ -12,13 +13,13 @@ import { useServerKeybindings } from "~/rpc/serverState"; · 8 unmodified lines | ||
| 12 | 13 | |
| 13 | 14 | function ChatRouteGlobalShortcuts() { |
| 14 | 15 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); |
| 15 | − const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); | |
| 16 | − const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } = | |
| 16 | + const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); | |
| 17 | + const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = | |
| 17 | 18 | useHandleNewThread(); |
| 18 | 19 | const keybindings = useServerKeybindings(); |
| 19 | 20 | const terminalOpen = useTerminalStateStore((state) => |
| 20 | − routeThreadId | |
| 21 | − ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen | |
| 21 | + routeThreadRef | |
| 22 | + ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen | |
| 22 | 23 | : false, |
| 23 | 24 | ); |
| 24 | 25 | const appSettings = useSettings(); |
| @@ -27,14 +28,18 @@ function ChatRouteGlobalShortcuts() { · 2 unmodified lines | ||
| 27 | 28 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 28 | 29 | if (event.defaultPrevented) return; |
| 29 | 30 | |
| 30 | − if (event.key === "Escape" && selectedThreadIdsSize > 0) { | |
| 31 | + if (event.key === "Escape" && selectedThreadKeysSize > 0) { | |
| 31 | 32 | event.preventDefault(); |
| 32 | 33 | clearSelection(); |
| 33 | 34 | return; |
| 34 | 35 | } |
| 35 | 36 | |
| 36 | − const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId; | |
| 37 | − if (!projectId) return; | |
| 37 | + const projectRef = activeThread | |
| 38 | + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) | |
| 39 | + : activeDraftThread && routeThreadRef | |
| 40 | + ? scopeProjectRef(routeThreadRef.environmentId, activeDraftThread.projectId) | |
| 41 | + : defaultProjectRef; | |
| 42 | + if (!projectRef) return; | |
| 38 | 43 | |
| 39 | 44 | const command = resolveShortcutCommand(event, keybindings, { |
| 40 | 45 | context: { |
| @@ -46,7 +51,7 @@ function ChatRouteGlobalShortcuts() { · 5 unmodified lines | ||
| 46 | 51 | if (command === "chat.newLocal") { |
| 47 | 52 | event.preventDefault(); |
| 48 | 53 | event.stopPropagation(); |
| 49 | − void handleNewThread(projectId, { | |
| 54 | + void handleNewThread(projectRef, { | |
| 50 | 55 | envMode: resolveSidebarNewThreadEnvMode({ |
| 51 | 56 | defaultEnvMode: appSettings.defaultThreadEnvMode, |
| 52 | 57 | }), |
| @@ -57,7 +62,7 @@ function ChatRouteGlobalShortcuts() { · 4 unmodified lines | ||
| 57 | 62 | if (command === "chat.new") { |
| 58 | 63 | event.preventDefault(); |
| 59 | 64 | event.stopPropagation(); |
| 60 | − void handleNewThread(projectId, { | |
| 65 | + void handleNewThread(projectRef, { | |
| 61 | 66 | branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, |
| 62 | 67 | worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, |
| 63 | 68 | envMode: |
| @@ -77,8 +82,9 @@ function ChatRouteGlobalShortcuts() { · 13 unmodified lines | ||
| 77 | 82 | clearSelection, |
| 78 | 83 | handleNewThread, |
| 79 | 84 | keybindings, |
| 80 | − defaultProjectId, | |
| 81 | − selectedThreadIdsSize, | |
| 85 | + defaultProjectRef, | |
| 86 | + routeThreadRef, | |
| 87 | + selectedThreadKeysSize, | |
| 82 | 88 | terminalOpen, |
| 83 | 89 | appSettings.defaultThreadEnvMode, |
| 84 | 90 | ]); |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | + EnvironmentId, | |
| 3 | 4 | ProjectId, |
| 4 | 5 | ThreadId, |
| 5 | 6 | type ServerConfig, |
| @@ -50,7 +51,21 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [ · 44 unmodified lines | ||
| 50 | 51 | }, |
| 51 | 52 | ]; |
| 52 | 53 | |
| 54 | +const baseEnvironment = { | |
| 55 | + environmentId: EnvironmentId.makeUnsafe("environment-local"), | |
| 56 | + label: "Local environment", | |
| 57 | + platform: { | |
| 58 | + os: "darwin" as const, | |
| 59 | + arch: "arm64" as const, | |
| 60 | + }, | |
| 61 | + serverVersion: "0.0.0-test", | |
| 62 | + capabilities: { | |
| 63 | + repositoryIdentity: true, | |
| 64 | + }, | |
| 65 | +}; | |
| 66 | + | |
| 53 | 67 | const baseServerConfig: ServerConfig = { |
| 68 | + environment: baseEnvironment, | |
| 54 | 69 | cwd: "/tmp/workspace", |
| 55 | 70 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", |
| 56 | 71 | keybindings: [], |
| @@ -193,6 +208,7 @@ describe("serverState", () => { · 136 unmodified lines | ||
| 193 | 208 | sequence: 1, |
| 194 | 209 | type: "welcome", |
| 195 | 210 | payload: { |
| 211 | + environment: baseEnvironment, | |
| 196 | 212 | cwd: "/tmp/workspace", |
| 197 | 213 | projectName: "t3-code", |
| 198 | 214 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), |
| @@ -201,6 +217,7 @@ describe("serverState", () => { · 2 unmodified lines | ||
| 201 | 217 | }); |
| 202 | 218 | |
| 203 | 219 | expect(listener).toHaveBeenCalledWith({ |
| 220 | + environment: baseEnvironment, | |
| 204 | 221 | cwd: "/tmp/workspace", |
| 205 | 222 | projectName: "t3-code", |
| 206 | 223 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), |
| @@ -210,6 +227,7 @@ describe("serverState", () => { · 3 unmodified lines | ||
| 210 | 227 | const lateListener = vi.fn(); |
| 211 | 228 | const unsubscribeLate = onWelcome(lateListener); |
| 212 | 229 | expect(lateListener).toHaveBeenCalledWith({ |
| 230 | + environment: baseEnvironment, | |
| 213 | 231 | cwd: "/tmp/workspace", |
| 214 | 232 | projectName: "t3-code", |
| 215 | 233 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | CheckpointRef, |
| 3 | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | + EnvironmentId, | |
| 4 | 5 | EventId, |
| 5 | 6 | MessageId, |
| 6 | 7 | ProjectId, |
| @@ -14,16 +15,49 @@ import { describe, expect, it } from "vitest"; · 7 unmodified lines | ||
| 14 | 15 | import { |
| 15 | 16 | applyOrchestrationEvent, |
| 16 | 17 | applyOrchestrationEvents, |
| 17 | − selectProjects, | |
| 18 | − selectThreads, | |
| 18 | + selectEnvironmentState, | |
| 19 | + selectProjectsAcrossEnvironments, | |
| 20 | + selectThreadsAcrossEnvironments, | |
| 19 | 21 | syncServerReadModel, |
| 20 | 22 | type AppState, |
| 23 | + type EnvironmentState, | |
| 21 | 24 | } from "./store"; |
| 22 | 25 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; |
| 23 | 26 | |
| 27 | +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); | |
| 28 | + | |
| 29 | +function withActiveEnvironmentState( | |
| 30 | + environmentState: EnvironmentState, | |
| 31 | + overrides: Partial<AppState & EnvironmentState> = {}, | |
| 32 | +): AppState { | |
| 33 | + const { | |
| 34 | + activeEnvironmentId: overrideActiveEnvironmentId, | |
| 35 | + environmentStateById: overrideEnvironmentStateById, | |
| 36 | + ...environmentOverrides | |
| 37 | + } = overrides; | |
| 38 | + const activeEnvironmentId = overrideActiveEnvironmentId ?? localEnvironmentId; | |
| 39 | + const mergedEnvironmentState = { | |
| 40 | + ...environmentState, | |
| 41 | + ...environmentOverrides, | |
| 42 | + }; | |
| 43 | + const environmentStateById = | |
| 44 | + overrideEnvironmentStateById ?? | |
| 45 | + (activeEnvironmentId | |
| 46 | + ? { | |
| 47 | + [activeEnvironmentId]: mergedEnvironmentState, | |
| 48 | + } | |
| 49 | + : {}); | |
| 50 | + | |
| 51 | + return { | |
| 52 | + activeEnvironmentId, | |
| 53 | + environmentStateById, | |
| 54 | + }; | |
| 55 | +} | |
| 56 | + | |
| 24 | 57 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 25 | 58 | return { |
| 26 | 59 | id: ThreadId.makeUnsafe("thread-1"), |
| 60 | + environmentId: localEnvironmentId, | |
| 27 | 61 | codexThreadId: null, |
| 28 | 62 | projectId: ProjectId.makeUnsafe("project-1"), |
| 29 | 63 | title: "Thread", |
| @@ -52,6 +86,7 @@ function makeState(thread: Thread): AppState { · 22 unmodified lines | ||
| 52 | 86 | const projectId = ProjectId.makeUnsafe("project-1"); |
| 53 | 87 | const project = { |
| 54 | 88 | id: projectId, |
| 89 | + environmentId: localEnvironmentId, | |
| 55 | 90 | name: "Project", |
| 56 | 91 | cwd: "/tmp/project", |
| 57 | 92 | defaultModelSelection: { |
| @@ -62,10 +97,10 @@ function makeState(thread: Thread): AppState { · 4 unmodified lines | ||
| 62 | 97 | updatedAt: "2026-02-13T00:00:00.000Z", |
| 63 | 98 | scripts: [], |
| 64 | 99 | }; |
| 65 | − const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { | |
| 100 | + const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = { | |
| 66 | 101 | [thread.projectId]: [thread.id], |
| 67 | 102 | }; |
| 68 | − return { | |
| 103 | + const environmentState = { | |
| 69 | 104 | projectIds: [projectId], |
| 70 | 105 | projectById: { |
| 71 | 106 | [projectId]: project, |
| @@ -75,6 +110,7 @@ function makeState(thread: Thread): AppState { · 3 unmodified lines | ||
| 75 | 110 | threadShellById: { |
| 76 | 111 | [thread.id]: { |
| 77 | 112 | id: thread.id, |
| 113 | + environmentId: thread.environmentId, | |
| 78 | 114 | codexThreadId: thread.codexThreadId, |
| 79 | 115 | projectId: thread.projectId, |
| 80 | 116 | title: thread.title, |
| @@ -106,7 +142,7 @@ function makeState(thread: Thread): AppState { · 25 unmodified lines | ||
| 106 | 142 | messageByThreadId: { |
| 107 | 143 | [thread.id]: Object.fromEntries( |
| 108 | 144 | thread.messages.map((message) => [message.id, message] as const), |
| 109 | − ) as AppState["messageByThreadId"][ThreadId], | |
| 145 | + ) as EnvironmentState["messageByThreadId"][ThreadId], | |
| 110 | 146 | }, |
| 111 | 147 | activityIdsByThreadId: { |
| 112 | 148 | [thread.id]: thread.activities.map((activity) => activity.id), |
| @@ -114,7 +150,7 @@ function makeState(thread: Thread): AppState { · 1 unmodified line | ||
| 114 | 150 | activityByThreadId: { |
| 115 | 151 | [thread.id]: Object.fromEntries( |
| 116 | 152 | thread.activities.map((activity) => [activity.id, activity] as const), |
| 117 | − ) as AppState["activityByThreadId"][ThreadId], | |
| 153 | + ) as EnvironmentState["activityByThreadId"][ThreadId], | |
| 118 | 154 | }, |
| 119 | 155 | proposedPlanIdsByThreadId: { |
| 120 | 156 | [thread.id]: thread.proposedPlans.map((plan) => plan.id), |
| @@ -122,7 +158,7 @@ function makeState(thread: Thread): AppState { · 1 unmodified line | ||
| 122 | 158 | proposedPlanByThreadId: { |
| 123 | 159 | [thread.id]: Object.fromEntries( |
| 124 | 160 | thread.proposedPlans.map((plan) => [plan.id, plan] as const), |
| 125 | − ) as AppState["proposedPlanByThreadId"][ThreadId], | |
| 161 | + ) as EnvironmentState["proposedPlanByThreadId"][ThreadId], | |
| 126 | 162 | }, |
| 127 | 163 | turnDiffIdsByThreadId: { |
| 128 | 164 | [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), |
| @@ -130,15 +166,16 @@ function makeState(thread: Thread): AppState { · 1 unmodified line | ||
| 130 | 166 | turnDiffSummaryByThreadId: { |
| 131 | 167 | [thread.id]: Object.fromEntries( |
| 132 | 168 | thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), |
| 133 | − ) as AppState["turnDiffSummaryByThreadId"][ThreadId], | |
| 169 | + ) as EnvironmentState["turnDiffSummaryByThreadId"][ThreadId], | |
| 134 | 170 | }, |
| 135 | 171 | sidebarThreadSummaryById: {}, |
| 136 | 172 | bootstrapComplete: true, |
| 137 | 173 | }; |
| 174 | + return withActiveEnvironmentState(environmentState); | |
| 138 | 175 | } |
| 139 | 176 | |
| 140 | −function makeEmptyState(overrides: Partial<AppState> = {}): AppState { | |
| 141 | − return { | |
| 177 | +function makeEmptyState(overrides: Partial<AppState & EnvironmentState> = {}): AppState { | |
| 178 | + const environmentState: EnvironmentState = { | |
| 142 | 179 | projectIds: [], |
| 143 | 180 | projectById: {}, |
| 144 | 181 | threadIds: [], |
| @@ -156,16 +193,20 @@ function makeEmptyState(overrides: Partial<AppState> = {}): AppState { · 11 unmodified lines | ||
| 156 | 193 | turnDiffSummaryByThreadId: {}, |
| 157 | 194 | sidebarThreadSummaryById: {}, |
| 158 | 195 | bootstrapComplete: true, |
| 159 | − ...overrides, | |
| 160 | 196 | }; |
| 197 | + return withActiveEnvironmentState(environmentState, overrides); | |
| 198 | +} | |
| 199 | + | |
| 200 | +function localEnvironmentStateOf(state: AppState): EnvironmentState { | |
| 201 | + return selectEnvironmentState(state, localEnvironmentId); | |
| 161 | 202 | } |
| 162 | 203 | |
| 163 | 204 | function projectsOf(state: AppState) { |
| 164 | − return selectProjects(state); | |
| 205 | + return selectProjectsAcrossEnvironments(state); | |
| 165 | 206 | } |
| 166 | 207 | |
| 167 | 208 | function threadsOf(state: AppState) { |
| 168 | − return selectThreads(state); | |
| 209 | + return selectThreadsAcrossEnvironments(state); | |
| 169 | 210 | } |
| 170 | 211 | |
| 171 | 212 | function makeEvent<T extends OrchestrationEvent["type"]>( |
| @@ -266,14 +307,20 @@ function makeReadModelProject( · 94 unmodified lines | ||
| 266 | 307 | |
| 267 | 308 | describe("store read model sync", () => { |
| 268 | 309 | it("marks bootstrap complete after snapshot sync", () => { |
| 269 | − const initialState: AppState = { | |
| 270 | − ...makeState(makeThread()), | |
| 271 | − bootstrapComplete: false, | |
| 272 | − }; | |
| 310 | + const initialState = withActiveEnvironmentState( | |
| 311 | + localEnvironmentStateOf(makeState(makeThread())), | |
| 312 | + { | |
| 313 | + bootstrapComplete: false, | |
| 314 | + }, | |
| 315 | + ); | |
| 273 | 316 | |
| 274 | − const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({}))); | |
| 317 | + const next = syncServerReadModel( | |
| 318 | + initialState, | |
| 319 | + makeReadModel(makeReadModelThread({})), | |
| 320 | + localEnvironmentId, | |
| 321 | + ); | |
| 275 | 322 | |
| 276 | − expect(next.bootstrapComplete).toBe(true); | |
| 323 | + expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true); | |
| 277 | 324 | }); |
| 278 | 325 | |
| 279 | 326 | it("preserves claude model slugs without an active session", () => { |
| @@ -287,7 +334,7 @@ describe("store read model sync", () => { · 7 unmodified lines | ||
| 287 | 334 | }), |
| 288 | 335 | ); |
| 289 | 336 | |
| 290 | − const next = syncServerReadModel(initialState, readModel); | |
| 337 | + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); | |
| 291 | 338 | |
| 292 | 339 | expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); |
| 293 | 340 | }); |
| @@ -312,7 +359,7 @@ describe("store read model sync", () => { · 18 unmodified lines | ||
| 312 | 359 | }), |
| 313 | 360 | ); |
| 314 | 361 | |
| 315 | − const next = syncServerReadModel(initialState, readModel); | |
| 362 | + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); | |
| 316 | 363 | |
| 317 | 364 | expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); |
| 318 | 365 | }); |
| @@ -325,7 +372,7 @@ describe("store read model sync", () => { · 6 unmodified lines | ||
| 325 | 372 | }), |
| 326 | 373 | ); |
| 327 | 374 | |
| 328 | − const next = syncServerReadModel(initialState, readModel); | |
| 375 | + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); | |
| 329 | 376 | |
| 330 | 377 | expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); |
| 331 | 378 | expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); |
| @@ -341,6 +388,7 @@ describe("store read model sync", () => { · 9 unmodified lines | ||
| 341 | 388 | archivedAt, |
| 342 | 389 | }), |
| 343 | 390 | ), |
| 391 | + localEnvironmentId, | |
| 344 | 392 | ); |
| 345 | 393 | |
| 346 | 394 | expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); |
| @@ -355,6 +403,7 @@ describe("store read model sync", () => { · 8 unmodified lines | ||
| 355 | 403 | projectById: { |
| 356 | 404 | [project2]: { |
| 357 | 405 | id: project2, |
| 406 | + environmentId: localEnvironmentId, | |
| 358 | 407 | name: "Project 2", |
| 359 | 408 | cwd: "/tmp/project-2", |
| 360 | 409 | defaultModelSelection: { |
| @@ -367,6 +416,7 @@ describe("store read model sync", () => { · 6 unmodified lines | ||
| 367 | 416 | }, |
| 368 | 417 | [project1]: { |
| 369 | 418 | id: project1, |
| 419 | + environmentId: localEnvironmentId, | |
| 370 | 420 | name: "Project 1", |
| 371 | 421 | cwd: "/tmp/project-1", |
| 372 | 422 | defaultModelSelection: { |
| @@ -402,7 +452,7 @@ describe("store read model sync", () => { · 29 unmodified lines | ||
| 402 | 452 | threads: [], |
| 403 | 453 | }; |
| 404 | 454 | |
| 405 | − const next = syncServerReadModel(initialState, readModel); | |
| 455 | + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); | |
| 406 | 456 | |
| 407 | 457 | expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); |
| 408 | 458 | }); |
| @@ -410,10 +460,9 @@ describe("store read model sync", () => { · 1 unmodified line | ||
| 410 | 460 | |
| 411 | 461 | describe("incremental orchestration updates", () => { |
| 412 | 462 | it("does not mark bootstrap complete for incremental events", () => { |
| 413 | − const state: AppState = { | |
| 414 | − ...makeState(makeThread()), | |
| 463 | + const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), { | |
| 415 | 464 | bootstrapComplete: false, |
| 416 | − }; | |
| 465 | + }); | |
| 417 | 466 | |
| 418 | 467 | const next = applyOrchestrationEvent( |
| 419 | 468 | state, |
| @@ -422,30 +471,10 @@ describe("incremental orchestration updates", () => { · 2 unmodified lines | ||
| 422 | 471 | title: "Updated title", |
| 423 | 472 | updatedAt: "2026-02-27T00:00:01.000Z", |
| 424 | 473 | }), |
| 474 | + localEnvironmentId, | |
| 425 | 475 | ); |
| 426 | 476 | |
| 427 | − expect(next.bootstrapComplete).toBe(false); | |
| 428 | − }); | |
| 429 | − | |
| 430 | − it("updates the existing project title when project.meta-updated arrives", () => { | |
| 431 | − const projectId = ProjectId.makeUnsafe("project-1"); | |
| 432 | − const state = makeState( | |
| 433 | − makeThread({ | |
| 434 | − projectId, | |
| 435 | − }), | |
| 436 | − ); | |
| 437 | − | |
| 438 | − const next = applyOrchestrationEvent( | |
| 439 | − state, | |
| 440 | − makeEvent("project.meta-updated", { | |
| 441 | − projectId, | |
| 442 | − title: "Renamed Project", | |
| 443 | − updatedAt: "2026-02-27T00:00:01.000Z", | |
| 444 | − }), | |
| 445 | − ); | |
| 446 | − | |
| 447 | − expect(next.projectById[projectId]?.name).toBe("Renamed Project"); | |
| 448 | − expect(next.projectById[projectId]?.updatedAt).toBe("2026-02-27T00:00:01.000Z"); | |
| 477 | + expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(false); | |
| 449 | 478 | }); |
| 450 | 479 | |
| 451 | 480 | it("preserves state identity for no-op project and thread deletes", () => { |
| @@ -458,6 +487,7 @@ describe("incremental orchestration updates", () => { · 6 unmodified lines | ||
| 458 | 487 | projectId: ProjectId.makeUnsafe("project-missing"), |
| 459 | 488 | deletedAt: "2026-02-27T00:00:01.000Z", |
| 460 | 489 | }), |
| 490 | + localEnvironmentId, | |
| 461 | 491 | ); |
| 462 | 492 | const nextAfterThreadDelete = applyOrchestrationEvent( |
| 463 | 493 | state, |
| @@ -465,6 +495,7 @@ describe("incremental orchestration updates", () => { · 1 unmodified line | ||
| 465 | 495 | threadId: ThreadId.makeUnsafe("thread-missing"), |
| 466 | 496 | deletedAt: "2026-02-27T00:00:01.000Z", |
| 467 | 497 | }), |
| 498 | + localEnvironmentId, | |
| 468 | 499 | ); |
| 469 | 500 | |
| 470 | 501 | expect(nextAfterProjectDelete).toBe(state); |
| @@ -479,6 +510,7 @@ describe("incremental orchestration updates", () => { · 8 unmodified lines | ||
| 479 | 510 | projectById: { |
| 480 | 511 | [originalProjectId]: { |
| 481 | 512 | id: originalProjectId, |
| 513 | + environmentId: localEnvironmentId, | |
| 482 | 514 | name: "Project", |
| 483 | 515 | cwd: "/tmp/project", |
| 484 | 516 | defaultModelSelection: { |
| @@ -506,15 +538,18 @@ describe("incremental orchestration updates", () => { · 21 unmodified lines | ||
| 506 | 538 | createdAt: "2026-02-27T00:00:01.000Z", |
| 507 | 539 | updatedAt: "2026-02-27T00:00:01.000Z", |
| 508 | 540 | }), |
| 541 | + localEnvironmentId, | |
| 509 | 542 | ); |
| 510 | 543 | |
| 511 | 544 | expect(projectsOf(next)).toHaveLength(1); |
| 512 | 545 | expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); |
| 513 | 546 | expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); |
| 514 | 547 | expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); |
| 515 | − expect(next.projectIds).toEqual([recreatedProjectId]); | |
| 516 | − expect(next.projectById[originalProjectId]).toBeUndefined(); | |
| 517 | − expect(next.projectById[recreatedProjectId]?.id).toBe(recreatedProjectId); | |
| 548 | + expect(localEnvironmentStateOf(next).projectIds).toEqual([recreatedProjectId]); | |
| 549 | + expect(localEnvironmentStateOf(next).projectById[originalProjectId]).toBeUndefined(); | |
| 550 | + expect(localEnvironmentStateOf(next).projectById[recreatedProjectId]?.id).toBe( | |
| 551 | + recreatedProjectId, | |
| 552 | + ); | |
| 518 | 553 | }); |
| 519 | 554 | |
| 520 | 555 | it("removes stale project index entries when thread.created recreates a thread under a new project", () => { |
| @@ -525,12 +560,12 @@ describe("incremental orchestration updates", () => { · 4 unmodified lines | ||
| 525 | 560 | id: threadId, |
| 526 | 561 | projectId: originalProjectId, |
| 527 | 562 | }); |
| 528 | − const state: AppState = { | |
| 529 | − ...makeState(thread), | |
| 563 | + const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(thread)), { | |
| 530 | 564 | projectIds: [originalProjectId, recreatedProjectId], |
| 531 | 565 | projectById: { |
| 532 | 566 | [originalProjectId]: { |
| 533 | 567 | id: originalProjectId, |
| 568 | + environmentId: localEnvironmentId, | |
| 534 | 569 | name: "Project 1", |
| 535 | 570 | cwd: "/tmp/project-1", |
| 536 | 571 | defaultModelSelection: { |
| @@ -543,6 +578,7 @@ describe("incremental orchestration updates", () => { · 6 unmodified lines | ||
| 543 | 578 | }, |
| 544 | 579 | [recreatedProjectId]: { |
| 545 | 580 | id: recreatedProjectId, |
| 581 | + environmentId: localEnvironmentId, | |
| 546 | 582 | name: "Project 2", |
| 547 | 583 | cwd: "/tmp/project-2", |
| 548 | 584 | defaultModelSelection: { |
| @@ -554,7 +590,7 @@ describe("incremental orchestration updates", () => { · 5 unmodified lines | ||
| 554 | 590 | scripts: [], |
| 555 | 591 | }, |
| 556 | 592 | }, |
| 557 | − }; | |
| 593 | + }); | |
| 558 | 594 | |
| 559 | 595 | const next = applyOrchestrationEvent( |
| 560 | 596 | state, |
| @@ -573,12 +609,15 @@ describe("incremental orchestration updates", () => { · 12 unmodified lines | ||
| 573 | 609 | createdAt: "2026-02-27T00:00:01.000Z", |
| 574 | 610 | updatedAt: "2026-02-27T00:00:01.000Z", |
| 575 | 611 | }), |
| 612 | + localEnvironmentId, | |
| 576 | 613 | ); |
| 577 | 614 | |
| 578 | 615 | expect(threadsOf(next)).toHaveLength(1); |
| 579 | 616 | expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); |
| 580 | − expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined(); | |
| 581 | − expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]); | |
| 617 | + expect(localEnvironmentStateOf(next).threadIdsByProjectId[originalProjectId]).toBeUndefined(); | |
| 618 | + expect(localEnvironmentStateOf(next).threadIdsByProjectId[recreatedProjectId]).toEqual([ | |
| 619 | + threadId, | |
| 620 | + ]); | |
| 582 | 621 | }); |
| 583 | 622 | |
| 584 | 623 | it("updates only the affected thread for message events", () => { |
| @@ -597,13 +636,15 @@ describe("incremental orchestration updates", () => { · 12 unmodified lines | ||
| 597 | 636 | ], |
| 598 | 637 | }); |
| 599 | 638 | const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") }); |
| 600 | − const state: AppState = { | |
| 601 | − ...makeState(thread1), | |
| 639 | + const baseState = makeState(thread1); | |
| 640 | + const baseEnvironmentState = localEnvironmentStateOf(baseState); | |
| 641 | + const state = withActiveEnvironmentState(baseEnvironmentState, { | |
| 602 | 642 | threadIds: [thread1.id, thread2.id], |
| 603 | 643 | threadShellById: { |
| 604 | − ...makeState(thread1).threadShellById, | |
| 644 | + ...baseEnvironmentState.threadShellById, | |
| 605 | 645 | [thread2.id]: { |
| 606 | 646 | id: thread2.id, |
| 647 | + environmentId: thread2.environmentId, | |
| 607 | 648 | codexThreadId: thread2.codexThreadId, |
| 608 | 649 | projectId: thread2.projectId, |
| 609 | 650 | title: thread2.title, |
| @@ -619,54 +660,54 @@ describe("incremental orchestration updates", () => { · 9 unmodified lines | ||
| 619 | 660 | }, |
| 620 | 661 | }, |
| 621 | 662 | threadSessionById: { |
| 622 | − ...makeState(thread1).threadSessionById, | |
| 663 | + ...baseEnvironmentState.threadSessionById, | |
| 623 | 664 | [thread2.id]: thread2.session, |
| 624 | 665 | }, |
| 625 | 666 | threadTurnStateById: { |
| 626 | − ...makeState(thread1).threadTurnStateById, | |
| 667 | + ...baseEnvironmentState.threadTurnStateById, | |
| 627 | 668 | [thread2.id]: { |
| 628 | 669 | latestTurn: thread2.latestTurn, |
| 629 | 670 | }, |
| 630 | 671 | }, |
| 631 | 672 | messageIdsByThreadId: { |
| 632 | − ...makeState(thread1).messageIdsByThreadId, | |
| 673 | + ...baseEnvironmentState.messageIdsByThreadId, | |
| 633 | 674 | [thread2.id]: [], |
| 634 | 675 | }, |
| 635 | 676 | messageByThreadId: { |
| 636 | − ...makeState(thread1).messageByThreadId, | |
| 677 | + ...baseEnvironmentState.messageByThreadId, | |
| 637 | 678 | [thread2.id]: {}, |
| 638 | 679 | }, |
| 639 | 680 | activityIdsByThreadId: { |
| 640 | − ...makeState(thread1).activityIdsByThreadId, | |
| 681 | + ...baseEnvironmentState.activityIdsByThreadId, | |
| 641 | 682 | [thread2.id]: [], |
| 642 | 683 | }, |
| 643 | 684 | activityByThreadId: { |
| 644 | − ...makeState(thread1).activityByThreadId, | |
| 685 | + ...baseEnvironmentState.activityByThreadId, | |
| 645 | 686 | [thread2.id]: {}, |
| 646 | 687 | }, |
| 647 | 688 | proposedPlanIdsByThreadId: { |
| 648 | − ...makeState(thread1).proposedPlanIdsByThreadId, | |
| 689 | + ...baseEnvironmentState.proposedPlanIdsByThreadId, | |
| 649 | 690 | [thread2.id]: [], |
| 650 | 691 | }, |
| 651 | 692 | proposedPlanByThreadId: { |
| 652 | − ...makeState(thread1).proposedPlanByThreadId, | |
| 693 | + ...baseEnvironmentState.proposedPlanByThreadId, | |
| 653 | 694 | [thread2.id]: {}, |
| 654 | 695 | }, |
| 655 | 696 | turnDiffIdsByThreadId: { |
| 656 | − ...makeState(thread1).turnDiffIdsByThreadId, | |
| 697 | + ...baseEnvironmentState.turnDiffIdsByThreadId, | |
| 657 | 698 | [thread2.id]: [], |
| 658 | 699 | }, |
| 659 | 700 | turnDiffSummaryByThreadId: { |
| 660 | − ...makeState(thread1).turnDiffSummaryByThreadId, | |
| 701 | + ...baseEnvironmentState.turnDiffSummaryByThreadId, | |
| 661 | 702 | [thread2.id]: {}, |
| 662 | 703 | }, |
| 663 | 704 | sidebarThreadSummaryById: { |
| 664 | − ...makeState(thread1).sidebarThreadSummaryById, | |
| 705 | + ...baseEnvironmentState.sidebarThreadSummaryById, | |
| 665 | 706 | }, |
| 666 | 707 | threadIdsByProjectId: { |
| 667 | 708 | [thread1.projectId]: [thread1.id, thread2.id], |
| 668 | 709 | }, |
| 669 | − }; | |
| 710 | + }); | |
| 670 | 711 | |
| 671 | 712 | const next = applyOrchestrationEvent( |
| 672 | 713 | state, |
| @@ -680,14 +721,25 @@ describe("incremental orchestration updates", () => { · 7 unmodified lines | ||
| 680 | 721 | createdAt: "2026-02-27T00:00:01.000Z", |
| 681 | 722 | updatedAt: "2026-02-27T00:00:01.000Z", |
| 682 | 723 | }), |
| 724 | + localEnvironmentId, | |
| 683 | 725 | ); |
| 684 | 726 | |
| 685 | 727 | expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); |
| 686 | 728 | expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); |
| 687 | − expect(next.threadShellById[thread2.id]).toBe(state.threadShellById[thread2.id]); | |
| 688 | − expect(next.threadSessionById[thread2.id]).toBe(state.threadSessionById[thread2.id]); | |
| 689 | − expect(next.messageIdsByThreadId[thread2.id]).toBe(state.messageIdsByThreadId[thread2.id]); | |
| 690 | − expect(next.messageByThreadId[thread2.id]).toBe(state.messageByThreadId[thread2.id]); | |
| 729 | + const nextEnvironmentState = next.environmentStateById[localEnvironmentId]; | |
| 730 | + const previousEnvironmentState = state.environmentStateById[localEnvironmentId]; | |
| 731 | + expect(nextEnvironmentState?.threadShellById[thread2.id]).toBe( | |
| 732 | + previousEnvironmentState?.threadShellById[thread2.id], | |
| 733 | + ); | |
| 734 | + expect(nextEnvironmentState?.threadSessionById[thread2.id]).toBe( | |
| 735 | + previousEnvironmentState?.threadSessionById[thread2.id], | |
| 736 | + ); | |
| 737 | + expect(nextEnvironmentState?.messageIdsByThreadId[thread2.id]).toBe( | |
| 738 | + previousEnvironmentState?.messageIdsByThreadId[thread2.id], | |
| 739 | + ); | |
| 740 | + expect(nextEnvironmentState?.messageByThreadId[thread2.id]).toBe( | |
| 741 | + previousEnvironmentState?.messageByThreadId[thread2.id], | |
| 742 | + ); | |
| 691 | 743 | }); |
| 692 | 744 | |
| 693 | 745 | it("applies replay batches in sequence and updates session state", () => { |
| @@ -703,38 +755,42 @@ describe("incremental orchestration updates", () => { · 9 unmodified lines | ||
| 703 | 755 | }); |
| 704 | 756 | const state = makeState(thread); |
| 705 | 757 | |
| 706 | − const next = applyOrchestrationEvents(state, [ | |
| 707 | − makeEvent( | |
| 708 | − "thread.session-set", | |
| 709 | − { | |
| 710 | − threadId: thread.id, | |
| 711 | − session: { | |
| 758 | + const next = applyOrchestrationEvents( | |
| 759 | + state, | |
| 760 | + [ | |
| 761 | + makeEvent( | |
| 762 | + "thread.session-set", | |
| 763 | + { | |
| 712 | 764 | threadId: thread.id, |
| 713 | − status: "running", | |
| 714 | − providerName: "codex", | |
| 715 | − runtimeMode: "full-access", | |
| 716 | − activeTurnId: TurnId.makeUnsafe("turn-1"), | |
| 717 | − lastError: null, | |
| 718 | − updatedAt: "2026-02-27T00:00:02.000Z", | |
| 765 | + session: { | |
| 766 | + threadId: thread.id, | |
| 767 | + status: "running", | |
| 768 | + providerName: "codex", | |
| 769 | + runtimeMode: "full-access", | |
| 770 | + activeTurnId: TurnId.makeUnsafe("turn-1"), | |
| 771 | + lastError: null, | |
| 772 | + updatedAt: "2026-02-27T00:00:02.000Z", | |
| 773 | + }, | |
| 719 | 774 | }, |
| 720 | − }, | |
| 721 | − { sequence: 2 }, | |
| 722 | − ), | |
| 723 | − makeEvent( | |
| 724 | − "thread.message-sent", | |
| 725 | − { | |
| 726 | − threadId: thread.id, | |
| 727 | − messageId: MessageId.makeUnsafe("assistant-1"), | |
| 728 | − role: "assistant", | |
| 729 | − text: "done", | |
| 730 | − turnId: TurnId.makeUnsafe("turn-1"), | |
| 731 | − streaming: false, | |
| 732 | − createdAt: "2026-02-27T00:00:03.000Z", | |
| 733 | − updatedAt: "2026-02-27T00:00:03.000Z", | |
| 734 | − }, | |
| 735 | − { sequence: 3 }, | |
| 736 | − ), | |
| 737 | − ]); | |
| 775 | + { sequence: 2 }, | |
| 776 | + ), | |
| 777 | + makeEvent( | |
| 778 | + "thread.message-sent", | |
| 779 | + { | |
| 780 | + threadId: thread.id, | |
| 781 | + messageId: MessageId.makeUnsafe("assistant-1"), | |
| 782 | + role: "assistant", | |
| 783 | + text: "done", | |
| 784 | + turnId: TurnId.makeUnsafe("turn-1"), | |
| 785 | + streaming: false, | |
| 786 | + createdAt: "2026-02-27T00:00:03.000Z", | |
| 787 | + updatedAt: "2026-02-27T00:00:03.000Z", | |
| 788 | + }, | |
| 789 | + { sequence: 3 }, | |
| 790 | + ), | |
| 791 | + ], | |
| 792 | + localEnvironmentId, | |
| 793 | + ); | |
| 738 | 794 | |
| 739 | 795 | expect(threadsOf(next)[0]?.session?.status).toBe("running"); |
| 740 | 796 | expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); |
| @@ -767,6 +823,7 @@ describe("incremental orchestration updates", () => { · 26 unmodified lines | ||
| 767 | 823 | assistantMessageId: MessageId.makeUnsafe("assistant-1"), |
| 768 | 824 | completedAt: "2026-02-27T00:00:04.000Z", |
| 769 | 825 | }), |
| 826 | + localEnvironmentId, | |
| 770 | 827 | ); |
| 771 | 828 | |
| 772 | 829 | expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); |
| @@ -811,6 +868,7 @@ describe("incremental orchestration updates", () => { · 38 unmodified lines | ||
| 811 | 868 | createdAt: "2026-02-27T00:00:03.000Z", |
| 812 | 869 | updatedAt: "2026-02-27T00:00:03.000Z", |
| 813 | 870 | }), |
| 871 | + localEnvironmentId, | |
| 814 | 872 | ); |
| 815 | 873 | |
| 816 | 874 | expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( |
| @@ -920,6 +978,7 @@ describe("incremental orchestration updates", () => { · 103 unmodified lines | ||
| 920 | 978 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 921 | 979 | turnCount: 1, |
| 922 | 980 | }), |
| 981 | + localEnvironmentId, | |
| 923 | 982 | ); |
| 924 | 983 | |
| 925 | 984 | expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ |
| @@ -978,6 +1037,7 @@ describe("incremental orchestration updates", () => { · 52 unmodified lines | ||
| 978 | 1037 | threadId: thread.id, |
| 979 | 1038 | turnCount: 1, |
| 980 | 1039 | }), |
| 1040 | + localEnvironmentId, | |
| 981 | 1041 | ); |
| 982 | 1042 | |
| 983 | 1043 | expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); |
| @@ -996,6 +1056,7 @@ describe("incremental orchestration updates", () => { · 12 unmodified lines | ||
| 996 | 1056 | updatedAt: "2026-02-27T00:00:04.000Z", |
| 997 | 1057 | }, |
| 998 | 1058 | }), |
| 1059 | + localEnvironmentId, | |
| 999 | 1060 | ); |
| 1000 | 1061 | |
| 1001 | 1062 | expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + type EnvironmentId, | |
| 2 | 3 | type MessageId, |
| 3 | 4 | type OrchestrationCheckpointSummary, |
| 4 | 5 | type OrchestrationEvent, |
| @@ -11,6 +12,8 @@ import { · 6 unmodified lines | ||
| 11 | 12 | type OrchestrationThreadActivity, |
| 12 | 13 | type ProjectId, |
| 13 | 14 | type ProviderKind, |
| 15 | + type ScopedProjectRef, | |
| 16 | + type ScopedThreadRef, | |
| 14 | 17 | ThreadId, |
| 15 | 18 | type TurnId, |
| 16 | 19 | } from "@t3tools/contracts"; |
| @@ -35,7 +38,7 @@ import { · 18 unmodified lines | ||
| 35 | 38 | } from "./types"; |
| 36 | 39 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; |
| 37 | 40 | |
| 38 | −export interface AppState { | |
| 41 | +export interface EnvironmentState { | |
| 39 | 42 | projectIds: ProjectId[]; |
| 40 | 43 | projectById: Record<ProjectId, Project>; |
| 41 | 44 | threadIds: ThreadId[]; |
| @@ -55,7 +58,12 @@ export interface AppState { · 13 unmodified lines | ||
| 55 | 58 | bootstrapComplete: boolean; |
| 56 | 59 | } |
| 57 | 60 | |
| 58 | −const initialState: AppState = { | |
| 61 | +export interface AppState { | |
| 62 | + activeEnvironmentId: EnvironmentId | null; | |
| 63 | + environmentStateById: Record<string, EnvironmentState>; | |
| 64 | +} | |
| 65 | + | |
| 66 | +const initialEnvironmentState: EnvironmentState = { | |
| 59 | 67 | projectIds: [], |
| 60 | 68 | projectById: {}, |
| 61 | 69 | threadIds: [], |
| @@ -75,6 +83,11 @@ const initialState: AppState = { · 13 unmodified lines | ||
| 75 | 83 | bootstrapComplete: false, |
| 76 | 84 | }; |
| 77 | 85 | |
| 86 | +const initialState: AppState = { | |
| 87 | + activeEnvironmentId: null, | |
| 88 | + environmentStateById: {}, | |
| 89 | +}; | |
| 90 | + | |
| 78 | 91 | const MAX_THREAD_MESSAGES = 2_000; |
| 79 | 92 | const MAX_THREAD_CHECKPOINTS = 500; |
| 80 | 93 | const MAX_THREAD_PROPOSED_PLANS = 200; |
| @@ -169,11 +182,16 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif · 88 unmodified lines | ||
| 169 | 182 | }; |
| 170 | 183 | } |
| 171 | 184 | |
| 172 | −function mapProject(project: OrchestrationReadModel["projects"][number]): Project { | |
| 185 | +function mapProject( | |
| 186 | + project: OrchestrationReadModel["projects"][number], | |
| 187 | + environmentId: EnvironmentId, | |
| 188 | +): Project { | |
| 173 | 189 | return { |
| 174 | 190 | id: project.id, |
| 191 | + environmentId, | |
| 175 | 192 | name: project.title, |
| 176 | 193 | cwd: project.workspaceRoot, |
| 194 | + repositoryIdentity: project.repositoryIdentity ?? null, | |
| 177 | 195 | defaultModelSelection: project.defaultModelSelection |
| 178 | 196 | ? normalizeModelSelection(project.defaultModelSelection) |
| 179 | 197 | : null, |
| @@ -183,9 +201,10 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec · 3 unmodified lines | ||
| 183 | 201 | }; |
| 184 | 202 | } |
| 185 | 203 | |
| 186 | −function mapThread(thread: OrchestrationThread): Thread { | |
| 204 | +function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread { | |
| 187 | 205 | return { |
| 188 | 206 | id: thread.id, |
| 207 | + environmentId, | |
| 189 | 208 | codexThreadId: null, |
| 190 | 209 | projectId: thread.projectId, |
| 191 | 210 | title: thread.title, |
| @@ -211,6 +230,7 @@ function mapThread(thread: OrchestrationThread): Thread { · 19 unmodified lines | ||
| 211 | 230 | function toThreadShell(thread: Thread): ThreadShell { |
| 212 | 231 | return { |
| 213 | 232 | id: thread.id, |
| 233 | + environmentId: thread.environmentId, | |
| 214 | 234 | codexThreadId: thread.codexThreadId, |
| 215 | 235 | projectId: thread.projectId, |
| 216 | 236 | title: thread.title, |
| @@ -251,6 +271,7 @@ function getLatestUserMessageAt(messages: ReadonlyArray<ChatMessage>): string | · 34 unmodified lines | ||
| 251 | 271 | function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { |
| 252 | 272 | return { |
| 253 | 273 | id: thread.id, |
| 274 | + environmentId: thread.environmentId, | |
| 254 | 275 | projectId: thread.projectId, |
| 255 | 276 | title: thread.title, |
| 256 | 277 | interactionMode: thread.interactionMode, |
| @@ -298,6 +319,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b · 41 unmodified lines | ||
| 298 | 319 | return ( |
| 299 | 320 | left !== undefined && |
| 300 | 321 | left.id === right.id && |
| 322 | + left.environmentId === right.environmentId && | |
| 301 | 323 | left.codexThreadId === right.codexThreadId && |
| 302 | 324 | left.projectId === right.projectId && |
| 303 | 325 | left.title === right.title && |
| @@ -377,7 +399,7 @@ function buildTurnDiffSlice(thread: Thread): { · 73 unmodified lines | ||
| 377 | 399 | }; |
| 378 | 400 | } |
| 379 | 401 | |
| 380 | −function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] { | |
| 402 | +function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] { | |
| 381 | 403 | const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; |
| 382 | 404 | const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; |
| 383 | 405 | if (ids.length === 0) { |
| @@ -390,7 +412,7 @@ function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[ · 6 unmodified lines | ||
| 390 | 412 | } |
| 391 | 413 | |
| 392 | 414 | function selectThreadActivities( |
| 393 | − state: AppState, | |
| 415 | + state: EnvironmentState, | |
| 394 | 416 | threadId: ThreadId, |
| 395 | 417 | ): OrchestrationThreadActivity[] { |
| 396 | 418 | const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; |
| @@ -404,7 +426,7 @@ function selectThreadActivities( · 7 unmodified lines | ||
| 404 | 426 | }); |
| 405 | 427 | } |
| 406 | 428 | |
| 407 | −function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] { | |
| 429 | +function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] { | |
| 408 | 430 | const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; |
| 409 | 431 | const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; |
| 410 | 432 | if (ids.length === 0) { |
| @@ -416,7 +438,10 @@ function selectThreadProposedPlans(state: AppState, threadId: ThreadId): Propose · 5 unmodified lines | ||
| 416 | 438 | }); |
| 417 | 439 | } |
| 418 | 440 | |
| 419 | −function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] { | |
| 441 | +function selectThreadTurnDiffSummaries( | |
| 442 | + state: EnvironmentState, | |
| 443 | + threadId: ThreadId, | |
| 444 | +): TurnDiffSummary[] { | |
| 420 | 445 | const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; |
| 421 | 446 | const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; |
| 422 | 447 | if (ids.length === 0) { |
| @@ -428,7 +453,7 @@ function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): Tur · 5 unmodified lines | ||
| 428 | 453 | }); |
| 429 | 454 | } |
| 430 | 455 | |
| 431 | −function getThread(state: AppState, threadId: ThreadId): Thread | undefined { | |
| 456 | +function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined { | |
| 432 | 457 | const shell = state.threadShellById[threadId]; |
| 433 | 458 | if (!shell) { |
| 434 | 459 | return undefined; |
| @@ -446,21 +471,25 @@ function getThread(state: AppState, threadId: ThreadId): Thread | undefined { · 11 unmodified lines | ||
| 446 | 471 | }; |
| 447 | 472 | } |
| 448 | 473 | |
| 449 | −function getProjects(state: AppState): Project[] { | |
| 474 | +function getProjects(state: EnvironmentState): Project[] { | |
| 450 | 475 | return state.projectIds.flatMap((projectId) => { |
| 451 | 476 | const project = state.projectById[projectId]; |
| 452 | 477 | return project ? [project] : []; |
| 453 | 478 | }); |
| 454 | 479 | } |
| 455 | 480 | |
| 456 | −function getThreads(state: AppState): Thread[] { | |
| 481 | +function getThreads(state: EnvironmentState): Thread[] { | |
| 457 | 482 | return state.threadIds.flatMap((threadId) => { |
| 458 | 483 | const thread = getThread(state, threadId); |
| 459 | 484 | return thread ? [thread] : []; |
| 460 | 485 | }); |
| 461 | 486 | } |
| 462 | 487 | |
| 463 | −function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState { | |
| 488 | +function writeThreadState( | |
| 489 | + state: EnvironmentState, | |
| 490 | + nextThread: Thread, | |
| 491 | + previousThread?: Thread, | |
| 492 | +): EnvironmentState { | |
| 464 | 493 | const nextShell = toThreadShell(nextThread); |
| 465 | 494 | const nextTurnState = toThreadTurnState(nextThread); |
| 466 | 495 | const previousShell = state.threadShellById[nextThread.id]; |
| @@ -613,7 +642,7 @@ function writeThreadState(state: AppState, nextThread: Thread, previousThread?: · 146 unmodified lines | ||
| 613 | 642 | return nextState; |
| 614 | 643 | } |
| 615 | 644 | |
| 616 | −function removeThreadState(state: AppState, threadId: ThreadId): AppState { | |
| 645 | +function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { | |
| 617 | 646 | const shell = state.threadShellById[threadId]; |
| 618 | 647 | if (!shell) { |
| 619 | 648 | return state; |
| @@ -887,10 +916,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string { · 267 unmodified lines | ||
| 887 | 916 | } |
| 888 | 917 | |
| 889 | 918 | function updateThreadState( |
| 890 | − state: AppState, | |
| 919 | + state: EnvironmentState, | |
| 891 | 920 | threadId: ThreadId, |
| 892 | 921 | updater: (thread: Thread) => Thread, |
| 893 | −): AppState { | |
| 922 | +): EnvironmentState { | |
| 894 | 923 | const currentThread = getThread(state, threadId); |
| 895 | 924 | if (!currentThread) { |
| 896 | 925 | return state; |
| @@ -904,7 +933,7 @@ function updateThreadState( · 7 unmodified lines | ||
| 904 | 933 | |
| 905 | 934 | function buildProjectState( |
| 906 | 935 | projects: ReadonlyArray<Project>, |
| 907 | −): Pick<AppState, "projectIds" | "projectById"> { | |
| 936 | +): Pick<EnvironmentState, "projectIds" | "projectById"> { | |
| 908 | 937 | return { |
| 909 | 938 | projectIds: projects.map((project) => project.id), |
| 910 | 939 | projectById: Object.fromEntries( |
| @@ -916,7 +945,7 @@ function buildProjectState( · 5 unmodified lines | ||
| 916 | 945 | function buildThreadState( |
| 917 | 946 | threads: ReadonlyArray<Thread>, |
| 918 | 947 | ): Pick< |
| 919 | − AppState, | |
| 948 | + EnvironmentState, | |
| 920 | 949 | | "threadIds" |
| 921 | 950 | | "threadIdsByProjectId" |
| 922 | 951 | | "threadShellById" |
| @@ -989,11 +1018,48 @@ function buildThreadState( · 66 unmodified lines | ||
| 989 | 1018 | }; |
| 990 | 1019 | } |
| 991 | 1020 | |
| 992 | −export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { | |
| 1021 | +function getStoredEnvironmentState( | |
| 1022 | + state: AppState, | |
| 1023 | + environmentId: EnvironmentId, | |
| 1024 | +): EnvironmentState { | |
| 1025 | + return state.environmentStateById[environmentId] ?? initialEnvironmentState; | |
| 1026 | +} | |
| 1027 | + | |
| 1028 | +function commitEnvironmentState( | |
| 1029 | + state: AppState, | |
| 1030 | + environmentId: EnvironmentId, | |
| 1031 | + nextEnvironmentState: EnvironmentState, | |
| 1032 | +): AppState { | |
| 1033 | + const currentEnvironmentState = state.environmentStateById[environmentId]; | |
| 1034 | + const environmentStateById = | |
| 1035 | + currentEnvironmentState === nextEnvironmentState | |
| 1036 | + ? state.environmentStateById | |
| 1037 | + : { | |
| 1038 | + ...state.environmentStateById, | |
| 1039 | + [environmentId]: nextEnvironmentState, | |
| 1040 | + }; | |
| 1041 | + | |
| 1042 | + if (environmentStateById === state.environmentStateById) { | |
| 1043 | + return state; | |
| 1044 | + } | |
| 1045 | + | |
| 1046 | + return { | |
| 1047 | + ...state, | |
| 1048 | + environmentStateById, | |
| 1049 | + }; | |
| 1050 | +} | |
| 1051 | + | |
| 1052 | +function syncEnvironmentReadModel( | |
| 1053 | + state: EnvironmentState, | |
| 1054 | + readModel: OrchestrationReadModel, | |
| 1055 | + environmentId: EnvironmentId, | |
| 1056 | +): EnvironmentState { | |
| 993 | 1057 | const projects = readModel.projects |
| 994 | 1058 | .filter((project) => project.deletedAt === null) |
| 995 | − .map(mapProject); | |
| 996 | − const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); | |
| 1059 | + .map((project) => mapProject(project, environmentId)); | |
| 1060 | + const threads = readModel.threads | |
| 1061 | + .filter((thread) => thread.deletedAt === null) | |
| 1062 | + .map((thread) => mapThread(thread, environmentId)); | |
| 997 | 1063 | return { |
| 998 | 1064 | ...state, |
| 999 | 1065 | ...buildProjectState(projects), |
| @@ -1002,19 +1068,43 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea · 2 unmodified lines | ||
| 1002 | 1068 | }; |
| 1003 | 1069 | } |
| 1004 | 1070 | |
| 1005 | −export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { | |
| 1071 | +export function syncServerReadModel( | |
| 1072 | + state: AppState, | |
| 1073 | + readModel: OrchestrationReadModel, | |
| 1074 | + environmentId: EnvironmentId, | |
| 1075 | +): AppState { | |
| 1076 | + return commitEnvironmentState( | |
| 1077 | + state, | |
| 1078 | + environmentId, | |
| 1079 | + syncEnvironmentReadModel( | |
| 1080 | + getStoredEnvironmentState(state, environmentId), | |
| 1081 | + readModel, | |
| 1082 | + environmentId, | |
| 1083 | + ), | |
| 1084 | + ); | |
| 1085 | +} | |
| 1086 | + | |
| 1087 | +function applyEnvironmentOrchestrationEvent( | |
| 1088 | + state: EnvironmentState, | |
| 1089 | + event: OrchestrationEvent, | |
| 1090 | + environmentId: EnvironmentId, | |
| 1091 | +): EnvironmentState { | |
| 1006 | 1092 | switch (event.type) { |
| 1007 | 1093 | case "project.created": { |
| 1008 | − const nextProject = mapProject({ | |
| 1009 | − id: event.payload.projectId, | |
| 1010 | − title: event.payload.title, | |
| 1011 | − workspaceRoot: event.payload.workspaceRoot, | |
| 1012 | − defaultModelSelection: event.payload.defaultModelSelection, | |
| 1013 | − scripts: event.payload.scripts, | |
| 1014 | − createdAt: event.payload.createdAt, | |
| 1015 | − updatedAt: event.payload.updatedAt, | |
| 1016 | − deletedAt: null, | |
| 1017 | − }); | |
| 1094 | + const nextProject = mapProject( | |
| 1095 | + { | |
| 1096 | + id: event.payload.projectId, | |
| 1097 | + title: event.payload.title, | |
| 1098 | + workspaceRoot: event.payload.workspaceRoot, | |
| 1099 | + repositoryIdentity: event.payload.repositoryIdentity ?? null, | |
| 1100 | + defaultModelSelection: event.payload.defaultModelSelection, | |
| 1101 | + scripts: event.payload.scripts, | |
| 1102 | + createdAt: event.payload.createdAt, | |
| 1103 | + updatedAt: event.payload.updatedAt, | |
| 1104 | + deletedAt: null, | |
| 1105 | + }, | |
| 1106 | + environmentId, | |
| 1107 | + ); | |
| 1018 | 1108 | const existingProjectId = |
| 1019 | 1109 | state.projectIds.find( |
| 1020 | 1110 | (projectId) => |
| @@ -1060,6 +1150,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve · 39 unmodified lines | ||
| 1060 | 1150 | ...project, |
| 1061 | 1151 | ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), |
| 1062 | 1152 | ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), |
| 1153 | + ...(event.payload.repositoryIdentity !== undefined | |
| 1154 | + ? { repositoryIdentity: event.payload.repositoryIdentity ?? null } | |
| 1155 | + : {}), | |
| 1063 | 1156 | ...(event.payload.defaultModelSelection !== undefined |
| 1064 | 1157 | ? { |
| 1065 | 1158 | defaultModelSelection: event.payload.defaultModelSelection |
| @@ -1095,26 +1188,29 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve · 29 unmodified lines | ||
| 1095 | 1188 | |
| 1096 | 1189 | case "thread.created": { |
| 1097 | 1190 | const previousThread = getThread(state, event.payload.threadId); |
| 1098 | − const nextThread = mapThread({ | |
| 1099 | − id: event.payload.threadId, | |
| 1100 | − projectId: event.payload.projectId, | |
| 1101 | − title: event.payload.title, | |
| 1102 | − modelSelection: event.payload.modelSelection, | |
| 1103 | − runtimeMode: event.payload.runtimeMode, | |
| 1104 | − interactionMode: event.payload.interactionMode, | |
| 1105 | − branch: event.payload.branch, | |
| 1106 | − worktreePath: event.payload.worktreePath, | |
| 1107 | − latestTurn: null, | |
| 1108 | − createdAt: event.payload.createdAt, | |
| 1109 | − updatedAt: event.payload.updatedAt, | |
| 1110 | − archivedAt: null, | |
| 1111 | − deletedAt: null, | |
| 1112 | − messages: [], | |
| 1113 | − proposedPlans: [], | |
| 1114 | − activities: [], | |
| 1115 | − checkpoints: [], | |
| 1116 | − session: null, | |
| 1117 | − }); | |
| 1191 | + const nextThread = mapThread( | |
| 1192 | + { | |
| 1193 | + id: event.payload.threadId, | |
| 1194 | + projectId: event.payload.projectId, | |
| 1195 | + title: event.payload.title, | |
| 1196 | + modelSelection: event.payload.modelSelection, | |
| 1197 | + runtimeMode: event.payload.runtimeMode, | |
| 1198 | + interactionMode: event.payload.interactionMode, | |
| 1199 | + branch: event.payload.branch, | |
| 1200 | + worktreePath: event.payload.worktreePath, | |
| 1201 | + latestTurn: null, | |
| 1202 | + createdAt: event.payload.createdAt, | |
| 1203 | + updatedAt: event.payload.updatedAt, | |
| 1204 | + archivedAt: null, | |
| 1205 | + deletedAt: null, | |
| 1206 | + messages: [], | |
| 1207 | + proposedPlans: [], | |
| 1208 | + activities: [], | |
| 1209 | + checkpoints: [], | |
| 1210 | + session: null, | |
| 1211 | + }, | |
| 1212 | + environmentId, | |
| 1213 | + ); | |
| 1118 | 1214 | return writeThreadState(state, nextThread, previousThread); |
| 1119 | 1215 | } |
| 1120 | 1216 | |
| @@ -1481,70 +1577,218 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve · 360 unmodified lines | ||
| 1481 | 1577 | export function applyOrchestrationEvents( |
| 1482 | 1578 | state: AppState, |
| 1483 | 1579 | events: ReadonlyArray<OrchestrationEvent>, |
| 1580 | + environmentId: EnvironmentId, | |
| 1484 | 1581 | ): AppState { |
| 1485 | 1582 | if (events.length === 0) { |
| 1486 | 1583 | return state; |
| 1487 | 1584 | } |
| 1488 | − return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); | |
| 1489 | −} | |
| 1490 | − | |
| 1491 | −export const selectProjects = (state: AppState): Project[] => getProjects(state); | |
| 1492 | −export const selectThreads = (state: AppState): Thread[] => getThreads(state); | |
| 1493 | −export const selectProjectById = | |
| 1494 | − (projectId: Project["id"] | null | undefined) => | |
| 1495 | − (state: AppState): Project | undefined => | |
| 1496 | − projectId ? state.projectById[projectId] : undefined; | |
| 1497 | −export const selectThreadById = | |
| 1498 | − (threadId: ThreadId | null | undefined) => | |
| 1499 | − (state: AppState): Thread | undefined => | |
| 1500 | − threadId ? getThread(state, threadId) : undefined; | |
| 1501 | −export const selectSidebarThreadSummaryById = | |
| 1502 | − (threadId: ThreadId | null | undefined) => | |
| 1503 | − (state: AppState): SidebarThreadSummary | undefined => | |
| 1504 | − threadId ? state.sidebarThreadSummaryById[threadId] : undefined; | |
| 1505 | −export const selectThreadIdsByProjectId = | |
| 1506 | − (projectId: ProjectId | null | undefined) => | |
| 1507 | − (state: AppState): ThreadId[] => | |
| 1508 | − projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; | |
| 1585 | + const currentEnvironmentState = getStoredEnvironmentState(state, environmentId); | |
| 1586 | + const nextEnvironmentState = events.reduce( | |
| 1587 | + (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId), | |
| 1588 | + currentEnvironmentState, | |
| 1589 | + ); | |
| 1590 | + return commitEnvironmentState(state, environmentId, nextEnvironmentState); | |
| 1591 | +} | |
| 1509 | 1592 | |
| 1510 | −export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { | |
| 1511 | − return updateThreadState(state, threadId, (thread) => { | |
| 1512 | − if (thread.error === error) return thread; | |
| 1513 | − return { ...thread, error }; | |
| 1593 | +function getEnvironmentEntries( | |
| 1594 | + state: AppState, | |
| 1595 | +): ReadonlyArray<readonly [EnvironmentId, EnvironmentState]> { | |
| 1596 | + return Object.entries(state.environmentStateById) as unknown as ReadonlyArray< | |
| 1597 | + readonly [EnvironmentId, EnvironmentState] | |
| 1598 | + >; | |
| 1599 | +} | |
| 1600 | + | |
| 1601 | +export function selectEnvironmentState( | |
| 1602 | + state: AppState, | |
| 1603 | + environmentId: EnvironmentId | null | undefined, | |
| 1604 | +): EnvironmentState { | |
| 1605 | + return environmentId ? getStoredEnvironmentState(state, environmentId) : initialEnvironmentState; | |
| 1606 | +} | |
| 1607 | + | |
| 1608 | +export function selectProjectsForEnvironment( | |
| 1609 | + state: AppState, | |
| 1610 | + environmentId: EnvironmentId | null | undefined, | |
| 1611 | +): Project[] { | |
| 1612 | + return getProjects(selectEnvironmentState(state, environmentId)); | |
| 1613 | +} | |
| 1614 | + | |
| 1615 | +export function selectThreadsForEnvironment( | |
| 1616 | + state: AppState, | |
| 1617 | + environmentId: EnvironmentId | null | undefined, | |
| 1618 | +): Thread[] { | |
| 1619 | + return getThreads(selectEnvironmentState(state, environmentId)); | |
| 1620 | +} | |
| 1621 | + | |
| 1622 | +export function selectProjectsAcrossEnvironments(state: AppState): Project[] { | |
| 1623 | + return getEnvironmentEntries(state).flatMap(([, environmentState]) => | |
| 1624 | + getProjects(environmentState), | |
| 1625 | + ); | |
| 1626 | +} | |
| 1627 | + | |
| 1628 | +export function selectThreadsAcrossEnvironments(state: AppState): Thread[] { | |
| 1629 | + return getEnvironmentEntries(state).flatMap(([, environmentState]) => | |
| 1630 | + getThreads(environmentState), | |
| 1631 | + ); | |
| 1632 | +} | |
| 1633 | + | |
| 1634 | +export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] { | |
| 1635 | + return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) => | |
| 1636 | + environmentState.threadIds.flatMap((threadId) => { | |
| 1637 | + const thread = environmentState.sidebarThreadSummaryById[threadId]; | |
| 1638 | + return thread && thread.environmentId === environmentId ? [thread] : []; | |
| 1639 | + }), | |
| 1640 | + ); | |
| 1641 | +} | |
| 1642 | + | |
| 1643 | +export function selectSidebarThreadsForProjectRef( | |
| 1644 | + state: AppState, | |
| 1645 | + ref: ScopedProjectRef | null | undefined, | |
| 1646 | +): SidebarThreadSummary[] { | |
| 1647 | + if (!ref) { | |
| 1648 | + return []; | |
| 1649 | + } | |
| 1650 | + | |
| 1651 | + const environmentState = selectEnvironmentState(state, ref.environmentId); | |
| 1652 | + const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? EMPTY_THREAD_IDS; | |
| 1653 | + return threadIds.flatMap((threadId) => { | |
| 1654 | + const thread = environmentState.sidebarThreadSummaryById[threadId]; | |
| 1655 | + return thread ? [thread] : []; | |
| 1514 | 1656 | }); |
| 1515 | 1657 | } |
| 1516 | 1658 | |
| 1659 | +export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean { | |
| 1660 | + return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete; | |
| 1661 | +} | |
| 1662 | + | |
| 1663 | +export function selectProjectByRef( | |
| 1664 | + state: AppState, | |
| 1665 | + ref: ScopedProjectRef | null | undefined, | |
| 1666 | +): Project | undefined { | |
| 1667 | + return ref | |
| 1668 | + ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] | |
| 1669 | + : undefined; | |
| 1670 | +} | |
| 1671 | + | |
| 1672 | +export function selectThreadByRef( | |
| 1673 | + state: AppState, | |
| 1674 | + ref: ScopedThreadRef | null | undefined, | |
| 1675 | +): Thread | undefined { | |
| 1676 | + return ref | |
| 1677 | + ? getThread(selectEnvironmentState(state, ref.environmentId), ref.threadId) | |
| 1678 | + : undefined; | |
| 1679 | +} | |
| 1680 | + | |
| 1681 | +export function selectSidebarThreadSummaryByRef( | |
| 1682 | + state: AppState, | |
| 1683 | + ref: ScopedThreadRef | null | undefined, | |
| 1684 | +): SidebarThreadSummary | undefined { | |
| 1685 | + return ref | |
| 1686 | + ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] | |
| 1687 | + : undefined; | |
| 1688 | +} | |
| 1689 | + | |
| 1690 | +export function selectThreadIdsByProjectRef( | |
| 1691 | + state: AppState, | |
| 1692 | + ref: ScopedProjectRef | null | undefined, | |
| 1693 | +): ThreadId[] { | |
| 1694 | + return ref | |
| 1695 | + ? (selectEnvironmentState(state, ref.environmentId).threadIdsByProjectId[ref.projectId] ?? | |
| 1696 | + EMPTY_THREAD_IDS) | |
| 1697 | + : EMPTY_THREAD_IDS; | |
| 1698 | +} | |
| 1699 | + | |
| 1700 | +export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { | |
| 1701 | + if (state.activeEnvironmentId === null) { | |
| 1702 | + return state; | |
| 1703 | + } | |
| 1704 | + | |
| 1705 | + const nextEnvironmentState = updateThreadState( | |
| 1706 | + getStoredEnvironmentState(state, state.activeEnvironmentId), | |
| 1707 | + threadId, | |
| 1708 | + (thread) => { | |
| 1709 | + if (thread.error === error) return thread; | |
| 1710 | + return { ...thread, error }; | |
| 1711 | + }, | |
| 1712 | + ); | |
| 1713 | + return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); | |
| 1714 | +} | |
| 1715 | + | |
| 1716 | +export function applyOrchestrationEvent( | |
| 1717 | + state: AppState, | |
| 1718 | + event: OrchestrationEvent, | |
| 1719 | + environmentId: EnvironmentId, | |
| 1720 | +): AppState { | |
| 1721 | + return commitEnvironmentState( | |
| 1722 | + state, | |
| 1723 | + environmentId, | |
| 1724 | + applyEnvironmentOrchestrationEvent( | |
| 1725 | + getStoredEnvironmentState(state, environmentId), | |
| 1726 | + event, | |
| 1727 | + environmentId, | |
| 1728 | + ), | |
| 1729 | + ); | |
| 1730 | +} | |
| 1731 | + | |
| 1732 | +export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { | |
| 1733 | + if (state.activeEnvironmentId === environmentId) { | |
| 1734 | + return state; | |
| 1735 | + } | |
| 1736 | + | |
| 1737 | + return { | |
| 1738 | + ...state, | |
| 1739 | + activeEnvironmentId: environmentId, | |
| 1740 | + }; | |
| 1741 | +} | |
| 1742 | + | |
| 1517 | 1743 | export function setThreadBranch( |
| 1518 | 1744 | state: AppState, |
| 1519 | 1745 | threadId: ThreadId, |
| 1520 | 1746 | branch: string | null, |
| 1521 | 1747 | worktreePath: string | null, |
| 1522 | 1748 | ): AppState { |
| 1523 | − return updateThreadState(state, threadId, (thread) => { | |
| 1524 | − if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; | |
| 1525 | − const cwdChanged = thread.worktreePath !== worktreePath; | |
| 1526 | − return { | |
| 1527 | − ...thread, | |
| 1528 | − branch, | |
| 1529 | − worktreePath, | |
| 1530 | − ...(cwdChanged ? { session: null } : {}), | |
| 1531 | − }; | |
| 1532 | − }); | |
| 1749 | + if (state.activeEnvironmentId === null) { | |
| 1750 | + return state; | |
| 1751 | + } | |
| 1752 | + | |
| 1753 | + const nextEnvironmentState = updateThreadState( | |
| 1754 | + getStoredEnvironmentState(state, state.activeEnvironmentId), | |
| 1755 | + threadId, | |
| 1756 | + (thread) => { | |
| 1757 | + if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; | |
| 1758 | + const cwdChanged = thread.worktreePath !== worktreePath; | |
| 1759 | + return { | |
| 1760 | + ...thread, | |
| 1761 | + branch, | |
| 1762 | + worktreePath, | |
| 1763 | + ...(cwdChanged ? { session: null } : {}), | |
| 1764 | + }; | |
| 1765 | + }, | |
| 1766 | + ); | |
| 1767 | + return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); | |
| 1533 | 1768 | } |
| 1534 | 1769 | |
| 1535 | 1770 | interface AppStore extends AppState { |
| 1536 | − syncServerReadModel: (readModel: OrchestrationReadModel) => void; | |
| 1537 | − applyOrchestrationEvent: (event: OrchestrationEvent) => void; | |
| 1538 | − applyOrchestrationEvents: (events: ReadonlyArray<OrchestrationEvent>) => void; | |
| 1771 | + setActiveEnvironmentId: (environmentId: EnvironmentId) => void; | |
| 1772 | + syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void; | |
| 1773 | + applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; | |
| 1774 | + applyOrchestrationEvents: ( | |
| 1775 | + events: ReadonlyArray<OrchestrationEvent>, | |
| 1776 | + environmentId: EnvironmentId, | |
| 1777 | + ) => void; | |
| 1539 | 1778 | setError: (threadId: ThreadId, error: string | null) => void; |
| 1540 | 1779 | setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; |
| 1541 | 1780 | } |
| 1542 | 1781 | |
| 1543 | 1782 | export const useStore = create<AppStore>((set) => ({ |
| 1544 | 1783 | ...initialState, |
| 1545 | − syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), | |
| 1546 | − applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), | |
| 1547 | − applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), | |
| 1784 | + setActiveEnvironmentId: (environmentId) => | |
| 1785 | + set((state) => setActiveEnvironmentId(state, environmentId)), | |
| 1786 | + syncServerReadModel: (readModel, environmentId) => | |
| 1787 | + set((state) => syncServerReadModel(state, readModel, environmentId)), | |
| 1788 | + applyOrchestrationEvent: (event, environmentId) => | |
| 1789 | + set((state) => applyOrchestrationEvent(state, event, environmentId)), | |
| 1790 | + applyOrchestrationEvents: (events, environmentId) => | |
| 1791 | + set((state) => applyOrchestrationEvents(state, events, environmentId)), | |
| 1548 | 1792 | setError: (threadId, error) => set((state) => setError(state, threadId, error)), |
| 1549 | 1793 | setThreadBranch: (threadId, branch, worktreePath) => |
| 1550 | 1794 | set((state) => setThreadBranch(state, threadId, branch, worktreePath)), |
| @@ -1,5 +1,11 @@ | ||
| 1 | −import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; | |
| 2 | −import { type AppState } from "./store"; | |
| 1 | +import { | |
| 2 | + type MessageId, | |
| 3 | + type ScopedProjectRef, | |
| 4 | + type ScopedThreadRef, | |
| 5 | + type ThreadId, | |
| 6 | + type TurnId, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | +import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; | |
| 3 | 9 | import { |
| 4 | 10 | type ChatMessage, |
| 5 | 11 | type Project, |
| @@ -30,54 +36,61 @@ function collectByIds<TKey extends string, TValue>( · 24 unmodified lines | ||
| 30 | 36 | }); |
| 31 | 37 | } |
| 32 | 38 | |
| 33 | −export function createProjectSelector( | |
| 34 | − projectId: ProjectId | null | undefined, | |
| 39 | +export function createProjectSelectorByRef( | |
| 40 | + ref: ScopedProjectRef | null | undefined, | |
| 35 | 41 | ): (state: AppState) => Project | undefined { |
| 36 | − return (state) => (projectId ? state.projectById[projectId] : undefined); | |
| 42 | + return (state) => | |
108 more lines aren’t shown here.
This diff is large: its 379 lines aren’t shown here.
This diff is large: its 509 lines aren’t shown here.
This diff is large: its 68 lines aren’t shown here.
This diff is large: its 60 lines aren’t shown here.
This diff is large: its 260 lines aren’t shown here.
This diff is large: its 173 lines aren’t shown here.
This diff is large: its 45 lines aren’t shown here.
This diff is large: its 63 lines aren’t shown here.
This diff is large: its 203 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 118 lines aren’t shown here.
This diff is large: its 60 lines aren’t shown here.
This diff is large: its 201 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 3 lines aren’t shown here.
This diff is large: its 64 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 65 lines aren’t shown here.
This diff is large: its 5 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 78 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 108 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 57 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.