| author | |
| committer | |
| log | b96308fc5adba6eecae5ee26efcfc8a01caaafd2 |
| tree | 3d7152b186f1f3f8fcad76bc3975b65644172c3b |
| parent | 72b7f90c5010cc43af87125b3e845a04589b7e5b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: justsomelegs <145564979+justsomelegs@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>123 files changed, 10829 insertions(+), 5592 deletions(-)
apps/desktop/src/main.ts+9| ... | ... | @@ -60,6 +60,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 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 { |
| 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; |
apps/desktop/src/preload.ts+8| ... | ... | @@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 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), |
apps/server/integration/OrchestrationEngineHarness.integration.ts+2| ... | ... | @@ -45,6 +45,7 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; |
| 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 = ( |
| 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), |
apps/server/src/config.ts+2| ... | ... | @@ -30,6 +30,7 @@ export interface ServerDerivedPaths { |
| 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* ( |
| 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 |
apps/server/src/environment/Layers/ServerEnvironment.test.ts created+124| ... | ... | @@ -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 | }); |
apps/server/src/environment/Layers/ServerEnvironment.ts created+94| ... | ... | @@ -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()); |
apps/server/src/environment/Services/ServerEnvironment.ts created+13| ... | ... | @@ -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") {} |
apps/server/src/git/Layers/GitManager.test.ts+3-3| ... | ... | @@ -854,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 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) => { |
| 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) => { |
| 1685 | 1685 | false, |
| 1686 | 1686 | ); |
| 1687 | 1687 | }), |
| 1688 | 12_000, | |
| 1688 | 20_000, | |
| 1689 | 1689 | ); |
| 1690 | 1690 | |
| 1691 | 1691 | it.effect( |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2| ... | ... | @@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; |
| 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", () => { |
| 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 |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5| ... | ... | @@ -19,6 +19,7 @@ import { |
| 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() { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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 | ); |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2| ... | ... | @@ -20,6 +20,7 @@ import { |
| 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( |
| 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(), { |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+6-1| ... | ... | @@ -4,6 +4,7 @@ import { Effect, Layer } from "effect"; |
| 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); |
| 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) => { |
| 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", |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+278-256| ... | ... | @@ -38,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh |
| 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 |
| 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* () { |
| 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* () { |
| 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 |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+2| ... | ... | @@ -28,6 +28,7 @@ import { |
| 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", () => { |
| 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( |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+2| ... | ... | @@ -29,6 +29,7 @@ import { |
| 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", () => { |
| 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( |
apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts created+193| ... | ... | @@ -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 | }); |
apps/server/src/project/Layers/RepositoryIdentityResolver.ts created+147| ... | ... | @@ -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 | ); |
apps/server/src/project/Services/RepositoryIdentityResolver.ts created+12| ... | ... | @@ -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") {} |
apps/server/src/server.test.ts+245-1| ... | ... | @@ -5,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 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 { |
| 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 = { |
| 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?: { |
| 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?: { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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({ |
apps/server/src/server.ts+4| ... | ... | @@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" |
| 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( |
| 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), |
apps/server/src/serverLifecycleEvents.test.ts+10| ... | ... | @@ -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( |
| 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( |
| 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")); |
apps/server/src/serverRuntimeStartup.ts+12-2| ... | ... | @@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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 |
apps/server/src/ws.ts+55-1| ... | ... | @@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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>; |
apps/web/index.html+1| ... | ... | @@ -12,6 +12,7 @@ |
| 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> |
apps/web/package.json+1| ... | ... | @@ -23,6 +23,7 @@ |
| 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", |
apps/web/src/components/BranchToolbar.tsx+36-14| ... | ... | @@ -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 = [ |
| 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 { |
| 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({ |
| 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({ |
| 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({ |
| 156 | 177 | )} |
| 157 | 178 | |
| 158 | 179 | <BranchToolbarBranchSelector |
| 180 | environmentId={environmentId} | |
| 159 | 181 | activeProjectCwd={activeProject.cwd} |
| 160 | 182 | activeThreadBranch={activeThreadBranch} |
| 161 | 183 | activeWorktreePath={activeWorktreePath} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+13-10| ... | ... | @@ -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 { |
| 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 { |
| 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: { |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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); |
apps/web/src/components/ChatMarkdown.tsx+2-2| ... | ... | @@ -22,7 +22,7 @@ import { fnv1a32 } from "../lib/diffRendering"; |
| 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) { |
| 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 { |
apps/web/src/components/ChatView.browser.tsx+330-229| ... | ... | @@ -4,6 +4,7 @@ import "../index.css"; |
| 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 { |
| 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"; |
| 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", () => ({ |
| 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 { |
| 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 { |
| 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 |
| 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> { |
| 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( |
| 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 { |
| 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 { |
| 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?: { |
| 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: { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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 | }, |
apps/web/src/components/ChatView.logic.test.ts+57-7| ... | ... | @@ -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 { |
| 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", () => { |
| 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?: { |
| 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?: { |
| 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>>) |
| 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>>) |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", |
apps/web/src/components/ChatView.logic.ts+36-9| ... | ... | @@ -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( |
| 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( |
| 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 { |
| 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( |
| 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); |
apps/web/src/components/ChatView.tsx+522-254| ... | ... | @@ -2,6 +2,7 @@ import { |
| 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 { |
| 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 { |
| 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 |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 |
| 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 |
| 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 = ( |
| 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: { |
| 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 { |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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 |
apps/web/src/components/DiffPanel.tsx+26-15| ... | ... | @@ -2,7 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs"; |
| 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"; |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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" }; |
apps/web/src/components/GitActionsControl.browser.tsx+73-47| ... | ... | @@ -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"; |
| 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 { |
| 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 { |
| 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 () => { |
| 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", () => ({ |
| 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() { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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 | } |
apps/web/src/components/GitActionsControl.tsx+42-20| ... | ... | @@ -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 { |
| 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 }) { |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 | > |
apps/web/src/components/KeybindingsToast.browser.tsx+26-23| ... | ... | @@ -2,6 +2,7 @@ import "../index.css"; |
| 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 } |
| 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", () => ({ |
| 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)?:\/\/.*/); |
| 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 { |
| 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> }> { |
| 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", () => { |
| 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 |
apps/web/src/components/PlanSidebar.tsx+6-3| ... | ... | @@ -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 { |
| 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 { |
| 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 { |
| 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({ |
| 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({ |
| 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"> |
apps/web/src/components/PullRequestThreadDialog.tsx+7-3| ... | ... | @@ -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"; |
| 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 { |
| 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({ |
| 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({ |
| 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 = |
apps/web/src/components/Sidebar.logic.test.ts+5-1| ... | ... | @@ -20,7 +20,7 @@ import { |
| 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 { |
| 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 { |
| 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 { |
| 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", |
apps/web/src/components/Sidebar.tsx+2083-1402| ... | ... | @@ -12,20 +12,7 @@ import { |
| 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"; |
| 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"; |
| 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 { |
| 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 { |
| 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 { |
| 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 = { |
| 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({ |
| 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( |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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 | </> |
apps/web/src/components/ThreadTerminalDrawer.browser.tsx created+247| ... | ... | @@ -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 | }); |
apps/web/src/components/ThreadTerminalDrawer.tsx+22-14| ... | ... | @@ -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 { |
| 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( |
| 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 { |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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 |
| 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({ |
| 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({ |
| 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"} |
apps/web/src/components/chat/ChatHeader.tsx+10-1| ... | ... | @@ -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"; |
| 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 { |
| 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({ |
| 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={ |
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+35-27| ... | ... | @@ -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"; |
| 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 |
| 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", () => { |
| 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 | }); |
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+20-18| ... | ... | @@ -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( |
| 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( |
| 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( |
| 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; |
apps/web/src/components/chat/MessagesTimeline.test.tsx+4| ... | ... | @@ -42,6 +42,8 @@ beforeAll(() => { |
| 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", () => { |
| 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", () => { |
| 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" |
apps/web/src/components/chat/MessagesTimeline.tsx+4-1| ... | ... | @@ -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 { |
| 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({ |
| 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({ |
| 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 | /> |
apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx+23-6| ... | ... | @@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = { |
| 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 { |
| 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 { |
| 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( |
| 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: { |
| 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>( |
| 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: { |
| 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: { |
| 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(); |
apps/web/src/components/chat/OpenInPicker.tsx+3-3| ... | ... | @@ -18,7 +18,7 @@ import { |
| 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({ |
| 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({ |
| 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; |
apps/web/src/components/chat/ProposedPlanCard.tsx+5-2| ... | ... | @@ -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 { |
| 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({ |
| 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; |
apps/web/src/components/chat/TraitsPicker.browser.tsx+30-24| ... | ... | @@ -6,10 +6,11 @@ import { |
| 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"; |
| 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: { |
| 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: { |
| 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: { |
| 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?: { |
| 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?: { |
| 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)", () => { |
| 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)", () => { |
| 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 |
| 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 |
| 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)", () => { |
| 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 | }); |
apps/web/src/components/chat/TraitsPicker.tsx+12-5| ... | ... | @@ -3,8 +3,8 @@ import { |
| 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 { |
| 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({ |
| 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 | ); |
apps/web/src/components/chat/composerProviderRegistry.test.tsx+33-1| ... | ... | @@ -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", () => { |
| 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 | }); |
apps/web/src/components/chat/composerProviderRegistry.tsx+93-53| ... | ... | @@ -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 = { |
| 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 = { |
| 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 = { |
| 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> = { |
| 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 |
| 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: { |
| 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: { |
| 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: { |
| 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, |
apps/web/src/components/settings/SettingsPanels.browser.tsx+18-6| ... | ... | @@ -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 { |
| 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", () => { |
| 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 |
apps/web/src/components/settings/SettingsPanels.tsx+36-35| ... | ... | @@ -13,11 +13,12 @@ import { useQueryClient } from "@tanstack/react-query"; |
| 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 { |
| 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) { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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" /> |
apps/web/src/composerDraftStore.test.ts+302-188| ... | ... | @@ -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"; |
| 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: { |
| 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 |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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 |
apps/web/src/composerDraftStore.ts+1572-948| ... | ... | @@ -3,6 +3,7 @@ import { |
| 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 { |
| 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"; |
| 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( |
| 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; |
| 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 & |
| 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({ |
| 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 { |
| 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 { |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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[] { |
| 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[] { |
| 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( |
| 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( |
| 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( |
| 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>()( |
| 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>()( |
| 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: { |
| 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 | } |
apps/web/src/editorPreferences.ts+2-2| ... | ... | @@ -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( |
| 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."); |
apps/web/src/environmentApi.ts created+67| ... | ... | @@ -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 | } |
apps/web/src/environmentBootstrap.ts created+65| ... | ... | @@ -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 | } |
apps/web/src/hooks/useHandleNewThread.ts+81-42| ... | ... | @@ -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 { |
| 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() { |
| 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 | } |
apps/web/src/hooks/useSettings.ts+4-6| ... | ... | @@ -26,7 +26,7 @@ import { |
| 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>): { |
| 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() { |
| 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 { |
| 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 |
apps/web/src/hooks/useThreadActions.ts+128-64| ... | ... | @@ -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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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 { |
apps/web/src/lib/gitReactQuery.test.ts+30-13| ... | ... | @@ -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> = { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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, |
apps/web/src/lib/gitReactQuery.ts+105-53| ... | ... | @@ -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 { |
| 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; |
| 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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 | } |
apps/web/src/lib/gitStatusState.test.ts+47-9| ... | ... | @@ -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 |
| 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(() => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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 | }); |
apps/web/src/lib/gitStatusState.ts+78-76| ... | ... | @@ -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 { |
| 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( |
| 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 { |
| 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 { |
| 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 { |
| 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 |
apps/web/src/lib/projectReactQuery.ts+17-8| ... | ... | @@ -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 = { |
| 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: { |
| 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 | }); |
apps/web/src/lib/providerReactQuery.test.ts+11-4| ... | ... | @@ -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(() => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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, |
apps/web/src/lib/providerReactQuery.ts+11-4| ... | ... | @@ -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 = { |
| 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) { |
| 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) { |
| 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)) { |
apps/web/src/lib/terminalStateCleanup.test.ts+20-13| ... | ... | @@ -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 | }); |
apps/web/src/lib/terminalStateCleanup.ts+9-11| ... | ... | @@ -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 | } |
apps/web/src/lib/utils.test.ts+41-1| ... | ... | @@ -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", () => { |
| 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 | }); |
apps/web/src/lib/utils.ts+7-14| ... | ... | @@ -4,6 +4,8 @@ import { type CxOptions, cx } from "class-variance-authority"; |
| 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()); |
| 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?: { |
| 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) { |
apps/web/src/localApi.test.ts created+444| ... | ... | @@ -0,0 +1,444 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | DEFAULT_SERVER_SETTINGS, | |
| 4 | type DesktopBridge, | |
| 5 | EnvironmentId, | |
| 6 | EventId, | |
| 7 | type GitStatusResult, | |
| 8 | ProjectId, | |
| 9 | type OrchestrationEvent, | |
| 10 | type ServerConfig, | |
| 11 | type ServerProvider, | |
| 12 | type TerminalEvent, | |
| 13 | ThreadId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 16 | ||
| 17 | import type { ContextMenuItem } from "@t3tools/contracts"; | |
| 18 | ||
| 19 | const showContextMenuFallbackMock = | |
| 20 | vi.fn< | |
| 21 | <T extends string>( | |
| 22 | items: readonly ContextMenuItem<T>[], | |
| 23 | position?: { x: number; y: number }, | |
| 24 | ) => Promise<T | null> | |
| 25 | >(); | |
| 26 | ||
| 27 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | |
| 28 | listeners.add(listener); | |
| 29 | return () => { | |
| 30 | listeners.delete(listener); | |
| 31 | }; | |
| 32 | } | |
| 33 | ||
| 34 | const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); | |
| 35 | const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); | |
| 36 | const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); | |
| 37 | ||
| 38 | const rpcClientMock = { | |
| 39 | dispose: vi.fn(), | |
| 40 | terminal: { | |
| 41 | open: vi.fn(), | |
| 42 | write: vi.fn(), | |
| 43 | resize: vi.fn(), | |
| 44 | clear: vi.fn(), | |
| 45 | restart: vi.fn(), | |
| 46 | close: vi.fn(), | |
| 47 | onEvent: vi.fn((listener: (event: TerminalEvent) => void) => | |
| 48 | registerListener(terminalEventListeners, listener), | |
| 49 | ), | |
| 50 | }, | |
| 51 | projects: { | |
| 52 | searchEntries: vi.fn(), | |
| 53 | writeFile: vi.fn(), | |
| 54 | }, | |
| 55 | shell: { | |
| 56 | openInEditor: vi.fn(), | |
| 57 | }, | |
| 58 | git: { | |
| 59 | pull: vi.fn(), | |
| 60 | refreshStatus: vi.fn(), | |
| 61 | onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => | |
| 62 | registerListener(gitStatusListeners, listener), | |
| 63 | ), | |
| 64 | runStackedAction: vi.fn(), | |
| 65 | listBranches: vi.fn(), | |
| 66 | createWorktree: vi.fn(), | |
| 67 | removeWorktree: vi.fn(), | |
| 68 | createBranch: vi.fn(), | |
| 69 | checkout: vi.fn(), | |
| 70 | init: vi.fn(), | |
| 71 | resolvePullRequest: vi.fn(), | |
| 72 | preparePullRequestThread: vi.fn(), | |
| 73 | }, | |
| 74 | server: { | |
| 75 | getConfig: vi.fn(), | |
| 76 | refreshProviders: vi.fn(), | |
| 77 | upsertKeybinding: vi.fn(), | |
| 78 | getSettings: vi.fn(), | |
| 79 | updateSettings: vi.fn(), | |
| 80 | subscribeConfig: vi.fn(), | |
| 81 | subscribeLifecycle: vi.fn(), | |
| 82 | }, | |
| 83 | orchestration: { | |
| 84 | getSnapshot: vi.fn(), | |
| 85 | dispatchCommand: vi.fn(), | |
| 86 | getTurnDiff: vi.fn(), | |
| 87 | getFullThreadDiff: vi.fn(), | |
| 88 | replayEvents: vi.fn(), | |
| 89 | onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) => | |
| 90 | registerListener(orchestrationEventListeners, listener), | |
| 91 | ), | |
| 92 | }, | |
| 93 | }; | |
| 94 | ||
| 95 | vi.mock("./wsRpcClient", () => { | |
| 96 | return { | |
| 97 | getWsRpcClient: () => rpcClientMock, | |
| 98 | getPrimaryWsRpcClientEntry: () => ({ | |
| 99 | key: "primary", | |
| 100 | knownEnvironment: { | |
| 101 | id: "primary", | |
| 102 | label: "Primary", | |
| 103 | source: "manual", | |
| 104 | target: { type: "ws", wsUrl: "ws://localhost:3000" }, | |
| 105 | }, | |
| 106 | client: rpcClientMock, | |
| 107 | environmentId: null, | |
| 108 | }), | |
| 109 | __resetWsRpcClientForTests: vi.fn(), | |
| 110 | }; | |
| 111 | }); | |
| 112 | ||
| 113 | vi.mock("./contextMenuFallback", () => ({ | |
| 114 | showContextMenuFallback: showContextMenuFallbackMock, | |
| 115 | })); | |
| 116 | ||
| 117 | function emitEvent<T>(listeners: Set<(event: T) => void>, event: T) { | |
| 118 | for (const listener of listeners) { | |
| 119 | listener(event); | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } { | |
| 124 | const testGlobal = globalThis as typeof globalThis & { | |
| 125 | window?: Window & typeof globalThis & { desktopBridge?: unknown }; | |
| 126 | }; | |
| 127 | if (!testGlobal.window) { | |
| 128 | testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown }; | |
| 129 | } | |
| 130 | return testGlobal.window; | |
| 131 | } | |
| 132 | ||
| 133 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { | |
| 134 | return { | |
| 135 | getWsUrl: () => null, | |
| 136 | getLocalEnvironmentBootstrap: () => null, | |
| 137 | pickFolder: async () => null, | |
| 138 | confirm: async () => true, | |
| 139 | setTheme: async () => undefined, | |
| 140 | showContextMenu: async () => null, | |
| 141 | openExternal: async () => true, | |
| 142 | onMenuAction: () => () => undefined, | |
| 143 | getUpdateState: async () => { | |
| 144 | throw new Error("getUpdateState not implemented in test"); | |
| 145 | }, | |
| 146 | checkForUpdate: async () => { | |
| 147 | throw new Error("checkForUpdate not implemented in test"); | |
| 148 | }, | |
| 149 | downloadUpdate: async () => { | |
| 150 | throw new Error("downloadUpdate not implemented in test"); | |
| 151 | }, | |
| 152 | installUpdate: async () => { | |
| 153 | throw new Error("installUpdate not implemented in test"); | |
| 154 | }, | |
| 155 | onUpdateState: () => () => undefined, | |
| 156 | ...overrides, | |
| 157 | }; | |
| 158 | } | |
| 159 | ||
| 160 | const defaultProviders: ReadonlyArray<ServerProvider> = [ | |
| 161 | { | |
| 162 | provider: "codex", | |
| 163 | enabled: true, | |
| 164 | installed: true, | |
| 165 | version: "0.116.0", | |
| 166 | status: "ready", | |
| 167 | auth: { status: "authenticated" }, | |
| 168 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 169 | models: [], | |
| 170 | }, | |
| 171 | ]; | |
| 172 | ||
| 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 | ||
| 186 | const baseServerConfig: ServerConfig = { | |
| 187 | environment: baseEnvironment, | |
| 188 | cwd: "/tmp/workspace", | |
| 189 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", | |
| 190 | keybindings: [], | |
| 191 | issues: [], | |
| 192 | providers: defaultProviders, | |
| 193 | availableEditors: ["cursor"], | |
| 194 | observability: { | |
| 195 | logsDirectoryPath: "/tmp/workspace/.config/logs", | |
| 196 | localTracingEnabled: true, | |
| 197 | otlpTracesEnabled: false, | |
| 198 | otlpMetricsEnabled: false, | |
| 199 | }, | |
| 200 | settings: DEFAULT_SERVER_SETTINGS, | |
| 201 | }; | |
| 202 | ||
| 203 | const baseGitStatus: GitStatusResult = { | |
| 204 | isRepo: true, | |
| 205 | hasOriginRemote: true, | |
| 206 | isDefaultBranch: false, | |
| 207 | branch: "feature/streamed", | |
| 208 | hasWorkingTreeChanges: false, | |
| 209 | workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 210 | hasUpstream: true, | |
| 211 | aheadCount: 0, | |
| 212 | behindCount: 0, | |
| 213 | pr: null, | |
| 214 | }; | |
| 215 | ||
| 216 | beforeEach(() => { | |
| 217 | vi.resetModules(); | |
| 218 | vi.clearAllMocks(); | |
| 219 | showContextMenuFallbackMock.mockReset(); | |
| 220 | terminalEventListeners.clear(); | |
| 221 | orchestrationEventListeners.clear(); | |
| 222 | gitStatusListeners.clear(); | |
| 223 | Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); | |
| 224 | }); | |
| 225 | ||
| 226 | afterEach(() => { | |
| 227 | vi.restoreAllMocks(); | |
| 228 | }); | |
| 229 | ||
| 230 | describe("wsApi", () => { | |
| 231 | it("forwards server config fetches directly to the RPC client", async () => { | |
| 232 | rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); | |
| 233 | const { createLocalApi } = await import("./localApi"); | |
| 234 | ||
| 235 | const api = createLocalApi(); | |
| 236 | ||
| 237 | await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); | |
| 238 | expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); | |
| 239 | expect(rpcClientMock.server.subscribeConfig).not.toHaveBeenCalled(); | |
| 240 | expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); | |
| 241 | }); | |
| 242 | ||
| 243 | it("forwards terminal and orchestration stream events", async () => { | |
| 244 | const { createEnvironmentApi } = await import("./environmentApi"); | |
| 245 | ||
| 246 | const api = createEnvironmentApi(rpcClientMock as never); | |
| 247 | const onTerminalEvent = vi.fn(); | |
| 248 | const onDomainEvent = vi.fn(); | |
| 249 | ||
| 250 | api.terminal.onEvent(onTerminalEvent); | |
| 251 | api.orchestration.onDomainEvent(onDomainEvent); | |
| 252 | ||
| 253 | const terminalEvent = { | |
| 254 | threadId: "thread-1", | |
| 255 | terminalId: "terminal-1", | |
| 256 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 257 | type: "output", | |
| 258 | data: "hello", | |
| 259 | } as const; | |
| 260 | emitEvent(terminalEventListeners, terminalEvent); | |
| 261 | ||
| 262 | const orchestrationEvent = { | |
| 263 | sequence: 1, | |
| 264 | eventId: EventId.makeUnsafe("event-1"), | |
| 265 | aggregateKind: "project", | |
| 266 | aggregateId: ProjectId.makeUnsafe("project-1"), | |
| 267 | occurredAt: "2026-02-24T00:00:00.000Z", | |
| 268 | commandId: null, | |
| 269 | causationEventId: null, | |
| 270 | correlationId: null, | |
| 271 | metadata: {}, | |
| 272 | type: "project.created", | |
| 273 | payload: { | |
| 274 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 275 | title: "Project", | |
| 276 | workspaceRoot: "/tmp/workspace", | |
| 277 | defaultModelSelection: { | |
| 278 | provider: "codex", | |
| 279 | model: "gpt-5-codex", | |
| 280 | }, | |
| 281 | scripts: [], | |
| 282 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 283 | updatedAt: "2026-02-24T00:00:00.000Z", | |
| 284 | }, | |
| 285 | } satisfies Extract<OrchestrationEvent, { type: "project.created" }>; | |
| 286 | emitEvent(orchestrationEventListeners, orchestrationEvent); | |
| 287 | ||
| 288 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); | |
| 289 | expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); | |
| 290 | }); | |
| 291 | ||
| 292 | it("forwards git status stream events", async () => { | |
| 293 | const { createEnvironmentApi } = await import("./environmentApi"); | |
| 294 | ||
| 295 | const api = createEnvironmentApi(rpcClientMock as never); | |
| 296 | const onStatus = vi.fn(); | |
| 297 | ||
| 298 | api.git.onStatus({ cwd: "/repo" }, onStatus); | |
| 299 | ||
| 300 | const gitStatus = baseGitStatus; | |
| 301 | emitEvent(gitStatusListeners, gitStatus); | |
| 302 | ||
| 303 | expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); | |
| 304 | expect(onStatus).toHaveBeenCalledWith(gitStatus); | |
| 305 | }); | |
| 306 | ||
| 307 | it("forwards git status refreshes directly to the RPC client", async () => { | |
| 308 | rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); | |
| 309 | const { createEnvironmentApi } = await import("./environmentApi"); | |
| 310 | ||
| 311 | const api = createEnvironmentApi(rpcClientMock as never); | |
| 312 | ||
| 313 | await api.git.refreshStatus({ cwd: "/repo" }); | |
| 314 | ||
| 315 | expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); | |
| 316 | }); | |
| 317 | ||
| 318 | it("forwards orchestration stream subscription options to the RPC client", async () => { | |
| 319 | const { createEnvironmentApi } = await import("./environmentApi"); | |
| 320 | ||
| 321 | const api = createEnvironmentApi(rpcClientMock as never); | |
| 322 | const onDomainEvent = vi.fn(); | |
| 323 | const onResubscribe = vi.fn(); | |
| 324 | ||
| 325 | api.orchestration.onDomainEvent(onDomainEvent, { onResubscribe }); | |
| 326 | ||
| 327 | expect(rpcClientMock.orchestration.onDomainEvent).toHaveBeenCalledWith(onDomainEvent, { | |
| 328 | onResubscribe, | |
| 329 | }); | |
| 330 | }); | |
| 331 | ||
| 332 | it("sends orchestration dispatch commands as the direct RPC payload", async () => { | |
| 333 | rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); | |
| 334 | const { createEnvironmentApi } = await import("./environmentApi"); | |
| 335 | ||
| 336 | const api = createEnvironmentApi(rpcClientMock as never); | |
| 337 | const command = { | |
| 338 | type: "project.create", | |
| 339 | commandId: CommandId.makeUnsafe("cmd-1"), | |
| 340 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 341 | title: "Project", | |
| 342 | workspaceRoot: "/tmp/project", | |
| 343 | defaultModelSelection: { | |
| 344 | provider: "codex", | |
| 345 | model: "gpt-5-codex", | |
| 346 | }, | |
| 347 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 348 | } as const; | |
| 349 | await api.orchestration.dispatchCommand(command); | |
| 350 | ||
| 351 | expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command); | |
| 352 | }); | |
| 353 | ||
| 354 | it("forwards workspace file writes to the project RPC", async () => { | |
| 355 | rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); | |
| 356 | const { createEnvironmentApi } = await import("./environmentApi"); | |
| 357 | ||
| 358 | const api = createEnvironmentApi(rpcClientMock as never); | |
| 359 | await api.projects.writeFile({ | |
| 360 | cwd: "/tmp/project", | |
| 361 | relativePath: "plan.md", | |
| 362 | contents: "# Plan\n", | |
| 363 | }); | |
| 364 | ||
| 365 | expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({ | |
| 366 | cwd: "/tmp/project", | |
| 367 | relativePath: "plan.md", | |
| 368 | contents: "# Plan\n", | |
| 369 | }); | |
| 370 | }); | |
| 371 | ||
| 372 | it("forwards full-thread diff requests to the orchestration RPC", async () => { | |
| 373 | rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); | |
| 374 | const { createEnvironmentApi } = await import("./environmentApi"); | |
| 375 | ||
| 376 | const api = createEnvironmentApi(rpcClientMock as never); | |
| 377 | await api.orchestration.getFullThreadDiff({ | |
| 378 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 379 | toTurnCount: 1, | |
| 380 | }); | |
| 381 | ||
| 382 | expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({ | |
| 383 | threadId: "thread-1", | |
| 384 | toTurnCount: 1, | |
| 385 | }); | |
| 386 | }); | |
| 387 | ||
| 388 | it("forwards provider refreshes directly to the RPC client", async () => { | |
| 389 | const nextProviders: ReadonlyArray<ServerProvider> = [ | |
| 390 | { | |
| 391 | ...defaultProviders[0]!, | |
| 392 | checkedAt: "2026-01-03T00:00:00.000Z", | |
| 393 | }, | |
| 394 | ]; | |
| 395 | rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); | |
| 396 | const { createLocalApi } = await import("./localApi"); | |
| 397 | ||
| 398 | const api = createLocalApi(); | |
| 399 | ||
| 400 | await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); | |
| 401 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); | |
| 402 | }); | |
| 403 | ||
| 404 | it("forwards server settings updates directly to the RPC client", async () => { | |
| 405 | const nextSettings = { | |
| 406 | ...DEFAULT_SERVER_SETTINGS, | |
| 407 | enableAssistantStreaming: true, | |
| 408 | }; | |
| 409 | rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); | |
| 410 | const { createLocalApi } = await import("./localApi"); | |
| 411 | ||
| 412 | const api = createLocalApi(); | |
| 413 | ||
| 414 | await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( | |
| 415 | nextSettings, | |
| 416 | ); | |
| 417 | expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({ | |
| 418 | enableAssistantStreaming: true, | |
| 419 | }); | |
| 420 | }); | |
| 421 | ||
| 422 | it("forwards context menu metadata to the desktop bridge", async () => { | |
| 423 | const showContextMenu = vi.fn().mockResolvedValue("delete"); | |
| 424 | getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); | |
| 425 | ||
| 426 | const { createLocalApi } = await import("./localApi"); | |
| 427 | const api = createLocalApi(); | |
| 428 | const items = [{ id: "delete", label: "Delete" }] as const; | |
| 429 | ||
| 430 | await expect(api.contextMenu.show(items)).resolves.toBe("delete"); | |
| 431 | expect(showContextMenu).toHaveBeenCalledWith(items, undefined); | |
| 432 | }); | |
| 433 | ||
| 434 | it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { | |
| 435 | showContextMenuFallbackMock.mockResolvedValue("rename"); | |
| 436 | const { createLocalApi } = await import("./localApi"); | |
| 437 | ||
| 438 | const api = createLocalApi(); | |
| 439 | const items = [{ id: "rename", label: "Rename" }] as const; | |
| 440 | ||
| 441 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); | |
| 442 | expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); | |
| 443 | }); | |
| 444 | }); |
apps/web/src/localApi.ts created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 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 | } |
apps/web/src/logicalProject.ts created+19| ... | ... | @@ -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 | } |
apps/web/src/nativeApi.ts deleted-31| ... | ... | @@ -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 | } |
apps/web/src/orchestrationEventEffects.test.ts+3-3| ... | ... | @@ -68,7 +68,7 @@ describe("deriveOrchestrationBatchEffects", () => { |
| 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", () => { |
| 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", () => { |
| 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 | }); |
apps/web/src/orchestrationEventEffects.ts+4-4| ... | ... | @@ -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( |
| 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( |
| 86 | 86 | } |
| 87 | 87 | |
| 88 | 88 | return { |
| 89 | clearPromotedDraftThreadIds, | |
| 89 | promoteDraftThreadIds, | |
| 90 | 90 | clearDeletedThreadIds, |
| 91 | 91 | removeTerminalStateThreadIds, |
| 92 | 92 | needsProviderInvalidation, |
apps/web/src/routeTree.gen.ts+39-17| ... | ... | @@ -14,7 +14,8 @@ import { Route as ChatRouteImport } from './routes/_chat' |
| 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({ |
| 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' { |
| 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) |
apps/web/src/routes/__root.tsx+319-166| ... | ... | @@ -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 { |
| 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 { |
| 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 { |
| 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"; |
| 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<{ |
| 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( |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 581 | 733 | applyTerminalEvent, |
| 582 | 734 | clearThreadUi, |
| 583 | 735 | setProjectExpanded, |
| 736 | setActiveEnvironmentId, | |
| 584 | 737 | syncProjects, |
| 585 | 738 | syncServerReadModel, |
| 586 | 739 | syncThreads, |
apps/web/src/routes/_chat.$environmentId.$threadId.tsx created+292| ... | ... | @@ -0,0 +1,292 @@ |
| 1 | import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; | |
| 2 | import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; | |
| 3 | ||
| 4 | import ChatView from "../components/ChatView"; | |
| 5 | import { threadHasStarted } from "../components/ChatView.logic"; | |
| 6 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; | |
| 7 | import { | |
| 8 | DiffPanelHeaderSkeleton, | |
| 9 | DiffPanelLoadingState, | |
| 10 | DiffPanelShell, | |
| 11 | type DiffPanelMode, | |
| 12 | } from "../components/DiffPanelShell"; | |
| 13 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; | |
| 14 | import { | |
| 15 | type DiffRouteSearch, | |
| 16 | parseDiffRouteSearch, | |
| 17 | stripDiffSearchParams, | |
| 18 | } from "../diffRouteSearch"; | |
| 19 | import { useMediaQuery } from "../hooks/useMediaQuery"; | |
| 20 | import { selectEnvironmentState, selectThreadByRef, useStore } from "../store"; | |
| 21 | import { createThreadSelectorByRef } from "../storeSelectors"; | |
| 22 | import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; | |
| 23 | import { Sheet, SheetPopup } from "../components/ui/sheet"; | |
| 24 | import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; | |
| 25 | ||
| 26 | const DiffPanel = lazy(() => import("../components/DiffPanel")); | |
| 27 | const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; | |
| 28 | const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; | |
| 29 | const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; | |
| 30 | const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; | |
| 31 | const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; | |
| 32 | ||
| 33 | const DiffPanelSheet = (props: { | |
| 34 | children: ReactNode; | |
| 35 | diffOpen: boolean; | |
| 36 | onCloseDiff: () => void; | |
| 37 | }) => { | |
| 38 | return ( | |
| 39 | <Sheet | |
| 40 | open={props.diffOpen} | |
| 41 | onOpenChange={(open) => { | |
| 42 | if (!open) { | |
| 43 | props.onCloseDiff(); | |
| 44 | } | |
| 45 | }} | |
| 46 | > | |
| 47 | <SheetPopup | |
| 48 | side="right" | |
| 49 | showCloseButton={false} | |
| 50 | keepMounted | |
| 51 | className="w-[min(88vw,820px)] max-w-[820px] p-0" | |
| 52 | > | |
| 53 | {props.children} | |
| 54 | </SheetPopup> | |
| 55 | </Sheet> | |
| 56 | ); | |
| 57 | }; | |
| 58 | ||
| 59 | const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { | |
| 60 | return ( | |
| 61 | <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}> | |
| 62 | <DiffPanelLoadingState label="Loading diff viewer..." /> | |
| 63 | </DiffPanelShell> | |
| 64 | ); | |
| 65 | }; | |
| 66 | ||
| 67 | const LazyDiffPanel = (props: { mode: DiffPanelMode }) => { | |
| 68 | return ( | |
| 69 | <DiffWorkerPoolProvider> | |
| 70 | <Suspense fallback={<DiffLoadingFallback mode={props.mode} />}> | |
| 71 | <DiffPanel mode={props.mode} /> | |
| 72 | </Suspense> | |
| 73 | </DiffWorkerPoolProvider> | |
| 74 | ); | |
| 75 | }; | |
| 76 | ||
| 77 | const DiffPanelInlineSidebar = (props: { | |
| 78 | diffOpen: boolean; | |
| 79 | onCloseDiff: () => void; | |
| 80 | onOpenDiff: () => void; | |
| 81 | renderDiffContent: boolean; | |
| 82 | }) => { | |
| 83 | const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props; | |
| 84 | const onOpenChange = useCallback( | |
| 85 | (open: boolean) => { | |
| 86 | if (open) { | |
| 87 | onOpenDiff(); | |
| 88 | return; | |
| 89 | } | |
| 90 | onCloseDiff(); | |
| 91 | }, | |
| 92 | [onCloseDiff, onOpenDiff], | |
| 93 | ); | |
| 94 | const shouldAcceptInlineSidebarWidth = useCallback( | |
| 95 | ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { | |
| 96 | const composerForm = document.querySelector<HTMLElement>("[data-chat-composer-form='true']"); | |
| 97 | if (!composerForm) return true; | |
| 98 | const composerViewport = composerForm.parentElement; | |
| 99 | if (!composerViewport) return true; | |
| 100 | const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width"); | |
| 101 | wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); | |
| 102 | ||
| 103 | const viewportStyle = window.getComputedStyle(composerViewport); | |
| 104 | const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0; | |
| 105 | const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0; | |
| 106 | const viewportContentWidth = Math.max( | |
| 107 | 0, | |
| 108 | composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, | |
| 109 | ); | |
| 110 | const formRect = composerForm.getBoundingClientRect(); | |
| 111 | const composerFooter = composerForm.querySelector<HTMLElement>( | |
| 112 | "[data-chat-composer-footer='true']", | |
| 113 | ); | |
| 114 | const composerRightActions = composerForm.querySelector<HTMLElement>( | |
| 115 | "[data-chat-composer-actions='right']", | |
| 116 | ); | |
| 117 | const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; | |
| 118 | const composerFooterGap = composerFooter | |
| 119 | ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || | |
| 120 | Number.parseFloat(window.getComputedStyle(composerFooter).gap) || | |
| 121 | 0 | |
| 122 | : 0; | |
| 123 | const minimumComposerWidth = | |
| 124 | COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; | |
| 125 | const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; | |
| 126 | const overflowsViewport = formRect.width > viewportContentWidth + 0.5; | |
| 127 | const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; | |
| 128 | ||
| 129 | if (previousSidebarWidth.length > 0) { | |
| 130 | wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); | |
| 131 | } else { | |
| 132 | wrapper.style.removeProperty("--sidebar-width"); | |
| 133 | } | |
| 134 | ||
| 135 | return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; | |
| 136 | }, | |
| 137 | [], | |
| 138 | ); | |
| 139 | ||
| 140 | return ( | |
| 141 | <SidebarProvider | |
| 142 | defaultOpen={false} | |
| 143 | open={diffOpen} | |
| 144 | onOpenChange={onOpenChange} | |
| 145 | className="w-auto min-h-0 flex-none bg-transparent" | |
| 146 | style={{ "--sidebar-width": DIFF_INLINE_DEFAULT_WIDTH } as React.CSSProperties} | |
| 147 | > | |
| 148 | <Sidebar | |
| 149 | side="right" | |
| 150 | collapsible="offcanvas" | |
| 151 | className="border-l border-border bg-card text-foreground" | |
| 152 | resizable={{ | |
| 153 | minWidth: DIFF_INLINE_SIDEBAR_MIN_WIDTH, | |
| 154 | shouldAcceptWidth: shouldAcceptInlineSidebarWidth, | |
| 155 | storageKey: DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY, | |
| 156 | }} | |
| 157 | > | |
| 158 | {renderDiffContent ? <LazyDiffPanel mode="sidebar" /> : null} | |
| 159 | <SidebarRail /> | |
| 160 | </Sidebar> | |
| 161 | </SidebarProvider> | |
| 162 | ); | |
| 163 | }; | |
| 164 | ||
| 165 | function ChatThreadRouteView() { | |
| 166 | const navigate = useNavigate(); | |
| 167 | const threadRef = Route.useParams({ | |
| 168 | select: (params) => resolveThreadRouteRef(params), | |
| 169 | }); | |
| 170 | const search = Route.useSearch(); | |
| 171 | const bootstrapComplete = useStore( | |
| 172 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, | |
| 173 | ); | |
| 174 | const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); | |
| 175 | const threadExists = useStore((store) => selectThreadByRef(store, threadRef) !== undefined); | |
| 176 | const environmentHasServerThreads = useStore( | |
| 177 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, | |
| 178 | ); | |
| 179 | const draftThreadExists = useComposerDraftStore((store) => | |
| 180 | threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, | |
| 181 | ); | |
| 182 | const draftThread = useComposerDraftStore((store) => | |
| 183 | threadRef ? store.getDraftThreadByRef(threadRef) : null, | |
| 184 | ); | |
| 185 | const environmentHasDraftThreads = useComposerDraftStore((store) => { | |
| 186 | if (!threadRef) { | |
| 187 | return false; | |
| 188 | } | |
| 189 | return store.hasDraftThreadsInEnvironment(threadRef.environmentId); | |
| 190 | }); | |
| 191 | const routeThreadExists = threadExists || draftThreadExists; | |
| 192 | const serverThreadStarted = threadHasStarted(serverThread); | |
| 193 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; | |
| 194 | const diffOpen = search.diff === "1"; | |
| 195 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); | |
| 196 | const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen); | |
| 197 | const closeDiff = useCallback(() => { | |
| 198 | if (!threadRef) { | |
| 199 | return; | |
| 200 | } | |
| 201 | void navigate({ | |
| 202 | to: "/$environmentId/$threadId", | |
| 203 | params: buildThreadRouteParams(threadRef), | |
| 204 | search: { diff: undefined }, | |
| 205 | }); | |
| 206 | }, [navigate, threadRef]); | |
| 207 | const openDiff = useCallback(() => { | |
| 208 | if (!threadRef) { | |
| 209 | return; | |
| 210 | } | |
| 211 | void navigate({ | |
| 212 | to: "/$environmentId/$threadId", | |
| 213 | params: buildThreadRouteParams(threadRef), | |
| 214 | search: (previous) => { | |
| 215 | const rest = stripDiffSearchParams(previous); | |
| 216 | return { ...rest, diff: "1" }; | |
| 217 | }, | |
| 218 | }); | |
| 219 | }, [navigate, threadRef]); | |
| 220 | ||
| 221 | useEffect(() => { | |
| 222 | if (diffOpen) { | |
| 223 | setHasOpenedDiff(true); | |
| 224 | } | |
| 225 | }, [diffOpen]); | |
| 226 | ||
| 227 | useEffect(() => { | |
| 228 | if (!threadRef || !bootstrapComplete) { | |
| 229 | return; | |
| 230 | } | |
| 231 | ||
| 232 | if (!routeThreadExists && environmentHasAnyThreads) { | |
| 233 | void navigate({ to: "/", replace: true }); | |
| 234 | } | |
| 235 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); | |
| 236 | ||
| 237 | useEffect(() => { | |
| 238 | if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) { | |
| 239 | return; | |
| 240 | } | |
| 241 | finalizePromotedDraftThreadByRef(threadRef); | |
| 242 | }, [draftThread?.promotedTo, serverThreadStarted, threadRef]); | |
| 243 | ||
| 244 | if (!threadRef || !bootstrapComplete || !routeThreadExists) { | |
| 245 | return null; | |
| 246 | } | |
| 247 | ||
| 248 | const shouldRenderDiffContent = diffOpen || hasOpenedDiff; | |
| 249 | ||
| 250 | if (!shouldUseDiffSheet) { | |
| 251 | return ( | |
| 252 | <> | |
| 253 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | |
| 254 | <ChatView | |
| 255 | environmentId={threadRef.environmentId} | |
| 256 | threadId={threadRef.threadId} | |
| 257 | routeKind="server" | |
| 258 | /> | |
| 259 | </SidebarInset> | |
| 260 | <DiffPanelInlineSidebar | |
| 261 | diffOpen={diffOpen} | |
| 262 | onCloseDiff={closeDiff} | |
| 263 | onOpenDiff={openDiff} | |
| 264 | renderDiffContent={shouldRenderDiffContent} | |
| 265 | /> | |
| 266 | </> | |
| 267 | ); | |
| 268 | } | |
| 269 | ||
| 270 | return ( | |
| 271 | <> | |
| 272 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | |
| 273 | <ChatView | |
| 274 | environmentId={threadRef.environmentId} | |
| 275 | threadId={threadRef.threadId} | |
| 276 | routeKind="server" | |
| 277 | /> | |
| 278 | </SidebarInset> | |
| 279 | <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}> | |
| 280 | {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null} | |
| 281 | </DiffPanelSheet> | |
| 282 | </> | |
| 283 | ); | |
| 284 | } | |
| 285 | ||
| 286 | export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ | |
| 287 | validateSearch: (search) => parseDiffRouteSearch(search), | |
| 288 | search: { | |
| 289 | middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])], | |
| 290 | }, | |
| 291 | component: ChatThreadRouteView, | |
| 292 | }); |
apps/web/src/routes/_chat.$threadId.tsx deleted-255| ... | ... | @@ -1,255 +0,0 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; | |
| 3 | import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react"; | |
| 4 | ||
| 5 | import ChatView from "../components/ChatView"; | |
| 6 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; | |
| 7 | import { | |
| 8 | DiffPanelHeaderSkeleton, | |
| 9 | DiffPanelLoadingState, | |
| 10 | DiffPanelShell, | |
| 11 | type DiffPanelMode, | |
| 12 | } from "../components/DiffPanelShell"; | |
| 13 | import { useComposerDraftStore } from "../composerDraftStore"; | |
| 14 | import { | |
| 15 | type DiffRouteSearch, | |
| 16 | parseDiffRouteSearch, | |
| 17 | stripDiffSearchParams, | |
| 18 | } from "../diffRouteSearch"; | |
| 19 | import { useMediaQuery } from "../hooks/useMediaQuery"; | |
| 20 | import { useStore } from "../store"; | |
| 21 | import { Sheet, SheetPopup } from "../components/ui/sheet"; | |
| 22 | import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; | |
| 23 | ||
| 24 | const DiffPanel = lazy(() => import("../components/DiffPanel")); | |
| 25 | const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; | |
| 26 | const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; | |
| 27 | const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; | |
| 28 | const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; | |
| 29 | const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; | |
| 30 | ||
| 31 | const DiffPanelSheet = (props: { | |
| 32 | children: ReactNode; | |
| 33 | diffOpen: boolean; | |
| 34 | onCloseDiff: () => void; | |
| 35 | }) => { | |
| 36 | return ( | |
| 37 | <Sheet | |
| 38 | open={props.diffOpen} | |
| 39 | onOpenChange={(open) => { | |
| 40 | if (!open) { | |
| 41 | props.onCloseDiff(); | |
| 42 | } | |
| 43 | }} | |
| 44 | > | |
| 45 | <SheetPopup | |
| 46 | side="right" | |
| 47 | showCloseButton={false} | |
| 48 | keepMounted | |
| 49 | className="w-[min(88vw,820px)] max-w-[820px] p-0" | |
| 50 | > | |
| 51 | {props.children} | |
| 52 | </SheetPopup> | |
| 53 | </Sheet> | |
| 54 | ); | |
| 55 | }; | |
| 56 | ||
| 57 | const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { | |
| 58 | return ( | |
| 59 | <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}> | |
| 60 | <DiffPanelLoadingState label="Loading diff viewer..." /> | |
| 61 | </DiffPanelShell> | |
| 62 | ); | |
| 63 | }; | |
| 64 | ||
| 65 | const LazyDiffPanel = (props: { mode: DiffPanelMode }) => { | |
| 66 | return ( | |
| 67 | <DiffWorkerPoolProvider> | |
| 68 | <Suspense fallback={<DiffLoadingFallback mode={props.mode} />}> | |
| 69 | <DiffPanel mode={props.mode} /> | |
| 70 | </Suspense> | |
| 71 | </DiffWorkerPoolProvider> | |
| 72 | ); | |
| 73 | }; | |
| 74 | ||
| 75 | const DiffPanelInlineSidebar = (props: { | |
| 76 | diffOpen: boolean; | |
| 77 | onCloseDiff: () => void; | |
| 78 | onOpenDiff: () => void; | |
| 79 | renderDiffContent: boolean; | |
| 80 | }) => { | |
| 81 | const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props; | |
| 82 | const onOpenChange = useCallback( | |
| 83 | (open: boolean) => { | |
| 84 | if (open) { | |
| 85 | onOpenDiff(); | |
| 86 | return; | |
| 87 | } | |
| 88 | onCloseDiff(); | |
| 89 | }, | |
| 90 | [onCloseDiff, onOpenDiff], | |
| 91 | ); | |
| 92 | const shouldAcceptInlineSidebarWidth = useCallback( | |
| 93 | ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { | |
| 94 | const composerForm = document.querySelector<HTMLElement>("[data-chat-composer-form='true']"); | |
| 95 | if (!composerForm) return true; | |
| 96 | const composerViewport = composerForm.parentElement; | |
| 97 | if (!composerViewport) return true; | |
| 98 | const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width"); | |
| 99 | wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); | |
| 100 | ||
| 101 | const viewportStyle = window.getComputedStyle(composerViewport); | |
| 102 | const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0; | |
| 103 | const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0; | |
| 104 | const viewportContentWidth = Math.max( | |
| 105 | 0, | |
| 106 | composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, | |
| 107 | ); | |
| 108 | const formRect = composerForm.getBoundingClientRect(); | |
| 109 | const composerFooter = composerForm.querySelector<HTMLElement>( | |
| 110 | "[data-chat-composer-footer='true']", | |
| 111 | ); | |
| 112 | const composerRightActions = composerForm.querySelector<HTMLElement>( | |
| 113 | "[data-chat-composer-actions='right']", | |
| 114 | ); | |
| 115 | const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; | |
| 116 | const composerFooterGap = composerFooter | |
| 117 | ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || | |
| 118 | Number.parseFloat(window.getComputedStyle(composerFooter).gap) || | |
| 119 | 0 | |
| 120 | : 0; | |
| 121 | const minimumComposerWidth = | |
| 122 | COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; | |
| 123 | const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; | |
| 124 | const overflowsViewport = formRect.width > viewportContentWidth + 0.5; | |
| 125 | const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; | |
| 126 | ||
| 127 | if (previousSidebarWidth.length > 0) { | |
| 128 | wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); | |
| 129 | } else { | |
| 130 | wrapper.style.removeProperty("--sidebar-width"); | |
| 131 | } | |
| 132 | ||
| 133 | return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; | |
| 134 | }, | |
| 135 | [], | |
| 136 | ); | |
| 137 | ||
| 138 | return ( | |
| 139 | <SidebarProvider | |
| 140 | defaultOpen={false} | |
| 141 | open={diffOpen} | |
| 142 | onOpenChange={onOpenChange} | |
| 143 | className="w-auto min-h-0 flex-none bg-transparent" | |
| 144 | style={{ "--sidebar-width": DIFF_INLINE_DEFAULT_WIDTH } as React.CSSProperties} | |
| 145 | > | |
| 146 | <Sidebar | |
| 147 | side="right" | |
| 148 | collapsible="offcanvas" | |
| 149 | className="border-l border-border bg-card text-foreground" | |
| 150 | resizable={{ | |
| 151 | minWidth: DIFF_INLINE_SIDEBAR_MIN_WIDTH, | |
| 152 | shouldAcceptWidth: shouldAcceptInlineSidebarWidth, | |
| 153 | storageKey: DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY, | |
| 154 | }} | |
| 155 | > | |
| 156 | {renderDiffContent ? <LazyDiffPanel mode="sidebar" /> : null} | |
| 157 | <SidebarRail /> | |
| 158 | </Sidebar> | |
| 159 | </SidebarProvider> | |
| 160 | ); | |
| 161 | }; | |
| 162 | ||
| 163 | function ChatThreadRouteView() { | |
| 164 | const bootstrapComplete = useStore((store) => store.bootstrapComplete); | |
| 165 | const navigate = useNavigate(); | |
| 166 | const threadId = Route.useParams({ | |
| 167 | select: (params) => ThreadId.makeUnsafe(params.threadId), | |
| 168 | }); | |
| 169 | const search = Route.useSearch(); | |
| 170 | const threadExists = useStore((store) => store.threadShellById[threadId] !== undefined); | |
| 171 | const draftThreadExists = useComposerDraftStore((store) => | |
| 172 | Object.hasOwn(store.draftThreadsByThreadId, threadId), | |
| 173 | ); | |
| 174 | const routeThreadExists = threadExists || draftThreadExists; | |
| 175 | const diffOpen = search.diff === "1"; | |
| 176 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); | |
| 177 | // TanStack Router keeps active route components mounted across param-only navigations | |
| 178 | // unless remountDeps are configured, so this stays warm across thread switches. | |
| 179 | const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen); | |
| 180 | const closeDiff = useCallback(() => { | |
| 181 | void navigate({ | |
| 182 | to: "/$threadId", | |
| 183 | params: { threadId }, | |
| 184 | search: { diff: undefined }, | |
| 185 | }); | |
| 186 | }, [navigate, threadId]); | |
| 187 | const openDiff = useCallback(() => { | |
| 188 | void navigate({ | |
| 189 | to: "/$threadId", | |
| 190 | params: { threadId }, | |
| 191 | search: (previous) => { | |
| 192 | const rest = stripDiffSearchParams(previous); | |
| 193 | return { ...rest, diff: "1" }; | |
| 194 | }, | |
| 195 | }); | |
| 196 | }, [navigate, threadId]); | |
| 197 | ||
| 198 | useEffect(() => { | |
| 199 | if (diffOpen) { | |
| 200 | setHasOpenedDiff(true); | |
| 201 | } | |
| 202 | }, [diffOpen]); | |
| 203 | ||
| 204 | useEffect(() => { | |
| 205 | if (!bootstrapComplete) { | |
| 206 | return; | |
| 207 | } | |
| 208 | ||
| 209 | if (!routeThreadExists) { | |
| 210 | void navigate({ to: "/", replace: true }); | |
| 211 | return; | |
| 212 | } | |
| 213 | }, [bootstrapComplete, navigate, routeThreadExists, threadId]); | |
| 214 | ||
| 215 | if (!bootstrapComplete || !routeThreadExists) { | |
| 216 | return null; | |
| 217 | } | |
| 218 | ||
| 219 | const shouldRenderDiffContent = diffOpen || hasOpenedDiff; | |
| 220 | ||
| 221 | if (!shouldUseDiffSheet) { | |
| 222 | return ( | |
| 223 | <> | |
| 224 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | |
| 225 | <ChatView threadId={threadId} /> | |
| 226 | </SidebarInset> | |
| 227 | <DiffPanelInlineSidebar | |
| 228 | diffOpen={diffOpen} | |
| 229 | onCloseDiff={closeDiff} | |
| 230 | onOpenDiff={openDiff} | |
| 231 | renderDiffContent={shouldRenderDiffContent} | |
| 232 | /> | |
| 233 | </> | |
| 234 | ); | |
| 235 | } | |
| 236 | ||
| 237 | return ( | |
| 238 | <> | |
| 239 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | |
| 240 | <ChatView threadId={threadId} /> | |
| 241 | </SidebarInset> | |
| 242 | <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}> | |
| 243 | {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null} | |
| 244 | </DiffPanelSheet> | |
| 245 | </> | |
| 246 | ); | |
| 247 | } | |
| 248 | ||
| 249 | export const Route = createFileRoute("/_chat/$threadId")({ | |
| 250 | validateSearch: (search) => parseDiffRouteSearch(search), | |
| 251 | search: { | |
| 252 | middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])], | |
| 253 | }, | |
| 254 | component: ChatThreadRouteView, | |
| 255 | }); |
apps/web/src/routes/_chat.draft.$draftId.tsx created+86| ... | ... | @@ -0,0 +1,86 @@ |
| 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 | }); |
apps/web/src/routes/_chat.tsx+17-11| ... | ... | @@ -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"; |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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 | ]); |
apps/web/src/rpc/serverState.test.ts+18| ... | ... | @@ -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> = [ |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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"), |
apps/web/src/store.test.ts+166-105| ... | ... | @@ -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"; |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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( |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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({ |
apps/web/src/store.ts+336-92| ... | ... | @@ -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 { |
| 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 { |
| 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 { |
| 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 = { |
| 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 |
| 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 |
| 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 { |
| 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 | |
| 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 |
| 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): { |
| 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[ |
| 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( |
| 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 |
| 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 |
| 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 { |
| 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?: |
| 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 { |
| 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( |
| 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( |
| 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( |
| 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 |
| 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 |
| 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 |
| 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 |
| 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)), |
apps/web/src/storeSelectors.ts+68-27| ... | ... | @@ -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>( |
| 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) => | |
| 43 | ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined; | |
| 37 | 44 | } |
| 38 | 45 | |
| 39 | export function createSidebarThreadSummarySelector( | |
| 40 | threadId: ThreadId | null | undefined, | |
| 46 | export function createSidebarThreadSummarySelectorByRef( | |
| 47 | ref: ScopedThreadRef | null | undefined, | |
| 41 | 48 | ): (state: AppState) => SidebarThreadSummary | undefined { |
| 42 | return (state) => (threadId ? state.sidebarThreadSummaryById[threadId] : undefined); | |
| 49 | return (state) => | |
| 50 | ref | |
| 51 | ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] | |
| 52 | : undefined; | |
| 43 | 53 | } |
| 44 | 54 | |
| 45 | export function createThreadSelector( | |
| 46 | threadId: ThreadId | null | undefined, | |
| 55 | function createScopedThreadSelector( | |
| 56 | resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, | |
| 47 | 57 | ): (state: AppState) => Thread | undefined { |
| 48 | let previousShell: AppState["threadShellById"][ThreadId] | undefined; | |
| 58 | let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined; | |
| 49 | 59 | let previousSession: ThreadSession | null | undefined; |
| 50 | 60 | let previousTurnState: ThreadTurnState | undefined; |
| 51 | 61 | let previousMessageIds: MessageId[] | undefined; |
| 52 | let previousMessagesById: AppState["messageByThreadId"][ThreadId] | undefined; | |
| 62 | let previousMessagesById: EnvironmentState["messageByThreadId"][ThreadId] | undefined; | |
| 53 | 63 | let previousActivityIds: string[] | undefined; |
| 54 | let previousActivitiesById: AppState["activityByThreadId"][ThreadId] | undefined; | |
| 64 | let previousActivitiesById: EnvironmentState["activityByThreadId"][ThreadId] | undefined; | |
| 55 | 65 | let previousProposedPlanIds: string[] | undefined; |
| 56 | let previousProposedPlansById: AppState["proposedPlanByThreadId"][ThreadId] | undefined; | |
| 66 | let previousProposedPlansById: EnvironmentState["proposedPlanByThreadId"][ThreadId] | undefined; | |
| 57 | 67 | let previousTurnDiffIds: TurnId[] | undefined; |
| 58 | let previousTurnDiffsById: AppState["turnDiffSummaryByThreadId"][ThreadId] | undefined; | |
| 68 | let previousTurnDiffsById: EnvironmentState["turnDiffSummaryByThreadId"][ThreadId] | undefined; | |
| 59 | 69 | let previousThread: Thread | undefined; |
| 60 | 70 | |
| 61 | 71 | return (state) => { |
| 62 | if (!threadId) { | |
| 72 | const ref = resolveRef(state); | |
| 73 | if (!ref) { | |
| 63 | 74 | return undefined; |
| 64 | 75 | } |
| 65 | 76 | |
| 66 | const shell = state.threadShellById[threadId]; | |
| 77 | const environmentState = selectEnvironmentState(state, ref.environmentId); | |
| 78 | const threadId = ref.threadId; | |
| 79 | const shell = environmentState.threadShellById[threadId]; | |
| 67 | 80 | if (!shell) { |
| 68 | 81 | return undefined; |
| 69 | 82 | } |
| 70 | 83 | |
| 71 | const session = state.threadSessionById[threadId] ?? null; | |
| 72 | const turnState = state.threadTurnStateById[threadId]; | |
| 73 | const messageIds = state.messageIdsByThreadId[threadId]; | |
| 74 | const messageById = state.messageByThreadId[threadId]; | |
| 75 | const activityIds = state.activityIdsByThreadId[threadId]; | |
| 76 | const activityById = state.activityByThreadId[threadId]; | |
| 77 | const proposedPlanIds = state.proposedPlanIdsByThreadId[threadId]; | |
| 78 | const proposedPlanById = state.proposedPlanByThreadId[threadId]; | |
| 79 | const turnDiffIds = state.turnDiffIdsByThreadId[threadId]; | |
| 80 | const turnDiffById = state.turnDiffSummaryByThreadId[threadId]; | |
| 84 | const session = environmentState.threadSessionById[threadId] ?? null; | |
| 85 | const turnState = environmentState.threadTurnStateById[threadId]; | |
| 86 | const messageIds = environmentState.messageIdsByThreadId[threadId]; | |
| 87 | const messageById = environmentState.messageByThreadId[threadId]; | |
| 88 | const activityIds = environmentState.activityIdsByThreadId[threadId]; | |
| 89 | const activityById = environmentState.activityByThreadId[threadId]; | |
| 90 | const proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; | |
| 91 | const proposedPlanById = environmentState.proposedPlanByThreadId[threadId]; | |
| 92 | const turnDiffIds = environmentState.turnDiffIdsByThreadId[threadId]; | |
| 93 | const turnDiffById = environmentState.turnDiffSummaryByThreadId[threadId]; | |
| 81 | 94 | |
| 82 | 95 | if ( |
| 83 | 96 | previousThread && |
| ... | ... | @@ -144,3 +157,31 @@ export function createThreadSelector( |
| 144 | 157 | return previousThread; |
| 145 | 158 | }; |
| 146 | 159 | } |
| 160 | ||
| 161 | export function createThreadSelectorByRef( | |
| 162 | ref: ScopedThreadRef | null | undefined, | |
| 163 | ): (state: AppState) => Thread | undefined { | |
| 164 | return createScopedThreadSelector(() => ref); | |
| 165 | } | |
| 166 | ||
| 167 | export function createThreadSelectorAcrossEnvironments( | |
| 168 | threadId: ThreadId | null | undefined, | |
| 169 | ): (state: AppState) => Thread | undefined { | |
| 170 | return createScopedThreadSelector((state) => { | |
| 171 | if (!threadId) { | |
| 172 | return undefined; | |
| 173 | } | |
| 174 | ||
| 175 | for (const [environmentId, environmentState] of Object.entries( | |
| 176 | state.environmentStateById, | |
| 177 | ) as Array<[ScopedThreadRef["environmentId"], EnvironmentState]>) { | |
| 178 | if (environmentState.threadShellById[threadId]) { | |
| 179 | return { | |
| 180 | environmentId, | |
| 181 | threadId, | |
| 182 | }; | |
| 183 | } | |
| 184 | } | |
| 185 | return undefined; | |
| 186 | }); | |
| 187 | } |
apps/web/src/terminalStateStore.test.ts+130-75| ... | ... | @@ -1,13 +1,17 @@ |
| 1 | import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; | |
| 1 | 2 | import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; |
| 2 | 3 | import { beforeEach, describe, expect, it } from "vitest"; |
| 3 | 4 | |
| 4 | 5 | import { |
| 6 | migratePersistedTerminalStateStoreState, | |
| 5 | 7 | selectTerminalEventEntries, |
| 6 | 8 | selectThreadTerminalState, |
| 7 | 9 | useTerminalStateStore, |
| 8 | 10 | } from "./terminalStateStore"; |
| 9 | 11 | |
| 10 | 12 | const THREAD_ID = ThreadId.makeUnsafe("thread-1"); |
| 13 | const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID); | |
| 14 | const OTHER_THREAD_REF = scopeThreadRef("environment-b" as never, THREAD_ID); | |
| 11 | 15 | |
| 12 | 16 | function makeTerminalEvent( |
| 13 | 17 | type: TerminalEvent["type"], |
| ... | ... | @@ -56,8 +60,8 @@ describe("terminalStateStore actions", () => { |
| 56 | 60 | beforeEach(() => { |
| 57 | 61 | useTerminalStateStore.persist.clearStorage(); |
| 58 | 62 | useTerminalStateStore.setState({ |
| 59 | terminalStateByThreadId: {}, | |
| 60 | terminalLaunchContextByThreadId: {}, | |
| 63 | terminalStateByThreadKey: {}, | |
| 64 | terminalLaunchContextByThreadKey: {}, | |
| 61 | 65 | terminalEventEntriesByKey: {}, |
| 62 | 66 | nextTerminalEventId: 1, |
| 63 | 67 | }); |
| ... | ... | @@ -65,8 +69,8 @@ describe("terminalStateStore actions", () => { |
| 65 | 69 | |
| 66 | 70 | it("returns a closed default terminal state for unknown threads", () => { |
| 67 | 71 | const terminalState = selectThreadTerminalState( |
| 68 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 69 | THREAD_ID, | |
| 72 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 73 | THREAD_REF, | |
| 70 | 74 | ); |
| 71 | 75 | expect(terminalState).toEqual({ |
| 72 | 76 | terminalOpen: false, |
| ... | ... | @@ -81,12 +85,12 @@ describe("terminalStateStore actions", () => { |
| 81 | 85 | |
| 82 | 86 | it("opens and splits terminals into the active group", () => { |
| 83 | 87 | const store = useTerminalStateStore.getState(); |
| 84 | store.setTerminalOpen(THREAD_ID, true); | |
| 85 | store.splitTerminal(THREAD_ID, "terminal-2"); | |
| 88 | store.setTerminalOpen(THREAD_REF, true); | |
| 89 | store.splitTerminal(THREAD_REF, "terminal-2"); | |
| 86 | 90 | |
| 87 | 91 | const terminalState = selectThreadTerminalState( |
| 88 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 89 | THREAD_ID, | |
| 92 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 93 | THREAD_REF, | |
| 90 | 94 | ); |
| 91 | 95 | expect(terminalState.terminalOpen).toBe(true); |
| 92 | 96 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); |
| ... | ... | @@ -98,14 +102,14 @@ describe("terminalStateStore actions", () => { |
| 98 | 102 | |
| 99 | 103 | it("caps splits at four terminals per group", () => { |
| 100 | 104 | const store = useTerminalStateStore.getState(); |
| 101 | store.splitTerminal(THREAD_ID, "terminal-2"); | |
| 102 | store.splitTerminal(THREAD_ID, "terminal-3"); | |
| 103 | store.splitTerminal(THREAD_ID, "terminal-4"); | |
| 104 | store.splitTerminal(THREAD_ID, "terminal-5"); | |
| 105 | store.splitTerminal(THREAD_REF, "terminal-2"); | |
| 106 | store.splitTerminal(THREAD_REF, "terminal-3"); | |
| 107 | store.splitTerminal(THREAD_REF, "terminal-4"); | |
| 108 | store.splitTerminal(THREAD_REF, "terminal-5"); | |
| 105 | 109 | |
| 106 | 110 | const terminalState = selectThreadTerminalState( |
| 107 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 108 | THREAD_ID, | |
| 111 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 112 | THREAD_REF, | |
| 109 | 113 | ); |
| 110 | 114 | expect(terminalState.terminalIds).toEqual([ |
| 111 | 115 | "default", |
| ... | ... | @@ -119,11 +123,11 @@ describe("terminalStateStore actions", () => { |
| 119 | 123 | }); |
| 120 | 124 | |
| 121 | 125 | it("creates new terminals in a separate group", () => { |
| 122 | useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2"); | |
| 126 | useTerminalStateStore.getState().newTerminal(THREAD_REF, "terminal-2"); | |
| 123 | 127 | |
| 124 | 128 | const terminalState = selectThreadTerminalState( |
| 125 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 126 | THREAD_ID, | |
| 129 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 130 | THREAD_REF, | |
| 127 | 131 | ); |
| 128 | 132 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); |
| 129 | 133 | expect(terminalState.activeTerminalId).toBe("terminal-2"); |
| ... | ... | @@ -136,11 +140,11 @@ describe("terminalStateStore actions", () => { |
| 136 | 140 | |
| 137 | 141 | it("ensures unknown server terminals are registered, opened, and activated", () => { |
| 138 | 142 | const store = useTerminalStateStore.getState(); |
| 139 | store.ensureTerminal(THREAD_ID, "setup-setup", { open: true, active: true }); | |
| 143 | store.ensureTerminal(THREAD_REF, "setup-setup", { open: true, active: true }); | |
| 140 | 144 | |
| 141 | 145 | const terminalState = selectThreadTerminalState( |
| 142 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 143 | THREAD_ID, | |
| 146 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 147 | THREAD_REF, | |
| 144 | 148 | ); |
| 145 | 149 | expect(terminalState.terminalOpen).toBe(true); |
| 146 | 150 | expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]); |
| ... | ... | @@ -151,69 +155,111 @@ describe("terminalStateStore actions", () => { |
| 151 | 155 | ]); |
| 152 | 156 | }); |
| 153 | 157 | |
| 154 | it("allows unlimited groups while keeping each group capped at four terminals", () => { | |
| 158 | it("keeps state isolated per environment when raw thread ids collide", () => { | |
| 155 | 159 | const store = useTerminalStateStore.getState(); |
| 156 | store.splitTerminal(THREAD_ID, "terminal-2"); | |
| 157 | store.splitTerminal(THREAD_ID, "terminal-3"); | |
| 158 | store.splitTerminal(THREAD_ID, "terminal-4"); | |
| 159 | store.newTerminal(THREAD_ID, "terminal-5"); | |
| 160 | store.newTerminal(THREAD_ID, "terminal-6"); | |
| 160 | store.setTerminalOpen(THREAD_REF, true); | |
| 161 | store.newTerminal(OTHER_THREAD_REF, "env-b-terminal"); | |
| 161 | 162 | |
| 162 | const terminalState = selectThreadTerminalState( | |
| 163 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 164 | THREAD_ID, | |
| 163 | expect( | |
| 164 | selectThreadTerminalState( | |
| 165 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 166 | THREAD_REF, | |
| 167 | ).terminalOpen, | |
| 168 | ).toBe(true); | |
| 169 | expect( | |
| 170 | selectThreadTerminalState( | |
| 171 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 172 | OTHER_THREAD_REF, | |
| 173 | ).terminalIds, | |
| 174 | ).toEqual(["default", "env-b-terminal"]); | |
| 175 | }); | |
| 176 | ||
| 177 | it("migrates v1 persisted terminal state using the stored version", () => { | |
| 178 | const migrated = migratePersistedTerminalStateStoreState( | |
| 179 | { | |
| 180 | terminalStateByThreadKey: { | |
| 181 | [scopedThreadKey(THREAD_REF)]: { | |
| 182 | terminalOpen: true, | |
| 183 | terminalHeight: 320, | |
| 184 | terminalIds: ["default"], | |
| 185 | runningTerminalIds: [], | |
| 186 | activeTerminalId: "default", | |
| 187 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | |
| 188 | activeTerminalGroupId: "group-default", | |
| 189 | }, | |
| 190 | "legacy-thread-id": { | |
| 191 | terminalOpen: true, | |
| 192 | terminalHeight: 320, | |
| 193 | terminalIds: ["default"], | |
| 194 | runningTerminalIds: [], | |
| 195 | activeTerminalId: "default", | |
| 196 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | |
| 197 | activeTerminalGroupId: "group-default", | |
| 198 | }, | |
| 199 | }, | |
| 200 | }, | |
| 201 | 1, | |
| 165 | 202 | ); |
| 166 | expect(terminalState.terminalIds).toEqual([ | |
| 167 | "default", | |
| 168 | "terminal-2", | |
| 169 | "terminal-3", | |
| 170 | "terminal-4", | |
| 171 | "terminal-5", | |
| 172 | "terminal-6", | |
| 173 | ]); | |
| 174 | expect(terminalState.terminalGroups).toEqual([ | |
| 175 | { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] }, | |
| 176 | { id: "group-terminal-5", terminalIds: ["terminal-5"] }, | |
| 177 | { id: "group-terminal-6", terminalIds: ["terminal-6"] }, | |
| 178 | ]); | |
| 203 | ||
| 204 | expect(migrated).toEqual({ | |
| 205 | terminalStateByThreadKey: { | |
| 206 | [scopedThreadKey(THREAD_REF)]: { | |
| 207 | terminalOpen: true, | |
| 208 | terminalHeight: 320, | |
| 209 | terminalIds: ["default"], | |
| 210 | runningTerminalIds: [], | |
| 211 | activeTerminalId: "default", | |
| 212 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | |
| 213 | activeTerminalGroupId: "group-default", | |
| 214 | }, | |
| 215 | }, | |
| 216 | }); | |
| 179 | 217 | }); |
| 180 | 218 | |
| 181 | 219 | it("tracks and clears terminal subprocess activity", () => { |
| 182 | 220 | const store = useTerminalStateStore.getState(); |
| 183 | store.splitTerminal(THREAD_ID, "terminal-2"); | |
| 184 | store.setTerminalActivity(THREAD_ID, "terminal-2", true); | |
| 221 | store.splitTerminal(THREAD_REF, "terminal-2"); | |
| 222 | store.setTerminalActivity(THREAD_REF, "terminal-2", true); | |
| 185 | 223 | expect( |
| 186 | selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) | |
| 187 | .runningTerminalIds, | |
| 224 | selectThreadTerminalState( | |
| 225 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 226 | THREAD_REF, | |
| 227 | ).runningTerminalIds, | |
| 188 | 228 | ).toEqual(["terminal-2"]); |
| 189 | 229 | |
| 190 | store.setTerminalActivity(THREAD_ID, "terminal-2", false); | |
| 230 | store.setTerminalActivity(THREAD_REF, "terminal-2", false); | |
| 191 | 231 | expect( |
| 192 | selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) | |
| 193 | .runningTerminalIds, | |
| 232 | selectThreadTerminalState( | |
| 233 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 234 | THREAD_REF, | |
| 235 | ).runningTerminalIds, | |
| 194 | 236 | ).toEqual([]); |
| 195 | 237 | }); |
| 196 | 238 | |
| 197 | 239 | it("resets to default and clears persisted entry when closing the last terminal", () => { |
| 198 | 240 | const store = useTerminalStateStore.getState(); |
| 199 | store.closeTerminal(THREAD_ID, "default"); | |
| 241 | store.closeTerminal(THREAD_REF, "default"); | |
| 200 | 242 | |
| 201 | expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined(); | |
| 202 | 243 | expect( |
| 203 | selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) | |
| 204 | .terminalIds, | |
| 244 | useTerminalStateStore.getState().terminalStateByThreadKey[scopedThreadKey(THREAD_REF)], | |
| 245 | ).toBeUndefined(); | |
| 246 | expect( | |
| 247 | selectThreadTerminalState( | |
| 248 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 249 | THREAD_REF, | |
| 250 | ).terminalIds, | |
| 205 | 251 | ).toEqual(["default"]); |
| 206 | 252 | }); |
| 207 | 253 | |
| 208 | 254 | it("keeps a valid active terminal after closing an active split terminal", () => { |
| 209 | 255 | const store = useTerminalStateStore.getState(); |
| 210 | store.splitTerminal(THREAD_ID, "terminal-2"); | |
| 211 | store.splitTerminal(THREAD_ID, "terminal-3"); | |
| 212 | store.closeTerminal(THREAD_ID, "terminal-3"); | |
| 256 | store.splitTerminal(THREAD_REF, "terminal-2"); | |
| 257 | store.splitTerminal(THREAD_REF, "terminal-3"); | |
| 258 | store.closeTerminal(THREAD_REF, "terminal-3"); | |
| 213 | 259 | |
| 214 | 260 | const terminalState = selectThreadTerminalState( |
| 215 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 216 | THREAD_ID, | |
| 261 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 262 | THREAD_REF, | |
| 217 | 263 | ); |
| 218 | 264 | expect(terminalState.activeTerminalId).toBe("terminal-2"); |
| 219 | 265 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); |
| ... | ... | @@ -224,12 +270,12 @@ describe("terminalStateStore actions", () => { |
| 224 | 270 | |
| 225 | 271 | it("buffers terminal events outside persisted terminal UI state", () => { |
| 226 | 272 | const store = useTerminalStateStore.getState(); |
| 227 | store.recordTerminalEvent(makeTerminalEvent("output")); | |
| 228 | store.recordTerminalEvent(makeTerminalEvent("activity")); | |
| 273 | store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output")); | |
| 274 | store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("activity")); | |
| 229 | 275 | |
| 230 | 276 | const entries = selectTerminalEventEntries( |
| 231 | 277 | useTerminalStateStore.getState().terminalEventEntriesByKey, |
| 232 | THREAD_ID, | |
| 278 | THREAD_REF, | |
| 233 | 279 | "default", |
| 234 | 280 | ); |
| 235 | 281 | |
| ... | ... | @@ -241,6 +287,7 @@ describe("terminalStateStore actions", () => { |
| 241 | 287 | it("applies started terminal events to terminal state, launch context, and event buffer", () => { |
| 242 | 288 | const store = useTerminalStateStore.getState(); |
| 243 | 289 | store.applyTerminalEvent( |
| 290 | THREAD_REF, | |
| 244 | 291 | makeTerminalEvent("started", { |
| 245 | 292 | terminalId: "setup-bootstrap", |
| 246 | 293 | snapshot: { |
| ... | ... | @@ -259,19 +306,23 @@ describe("terminalStateStore actions", () => { |
| 259 | 306 | ); |
| 260 | 307 | |
| 261 | 308 | const terminalState = selectThreadTerminalState( |
| 262 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 263 | THREAD_ID, | |
| 309 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 310 | THREAD_REF, | |
| 264 | 311 | ); |
| 265 | 312 | const entries = selectTerminalEventEntries( |
| 266 | 313 | useTerminalStateStore.getState().terminalEventEntriesByKey, |
| 267 | THREAD_ID, | |
| 314 | THREAD_REF, | |
| 268 | 315 | "setup-bootstrap", |
| 269 | 316 | ); |
| 270 | 317 | |
| 271 | 318 | expect(terminalState.terminalOpen).toBe(true); |
| 272 | 319 | expect(terminalState.activeTerminalId).toBe("setup-bootstrap"); |
| 273 | 320 | expect(terminalState.terminalIds).toEqual(["default", "setup-bootstrap"]); |
| 274 | expect(useTerminalStateStore.getState().terminalLaunchContextByThreadId[THREAD_ID]).toEqual({ | |
| 321 | expect( | |
| 322 | useTerminalStateStore.getState().terminalLaunchContextByThreadKey[ | |
| 323 | scopedThreadKey(THREAD_REF) | |
| 324 | ], | |
| 325 | ).toEqual({ | |
| 275 | 326 | cwd: "/tmp/worktree", |
| 276 | 327 | worktreePath: "/tmp/worktree", |
| 277 | 328 | }); |
| ... | ... | @@ -281,20 +332,24 @@ describe("terminalStateStore actions", () => { |
| 281 | 332 | |
| 282 | 333 | it("applies activity and exited terminal events to subprocess state while buffering events", () => { |
| 283 | 334 | const store = useTerminalStateStore.getState(); |
| 284 | store.ensureTerminal(THREAD_ID, "terminal-2", { open: true, active: true }); | |
| 335 | store.ensureTerminal(THREAD_REF, "terminal-2", { open: true, active: true }); | |
| 285 | 336 | |
| 286 | 337 | store.applyTerminalEvent( |
| 338 | THREAD_REF, | |
| 287 | 339 | makeTerminalEvent("activity", { |
| 288 | 340 | terminalId: "terminal-2", |
| 289 | 341 | hasRunningSubprocess: true, |
| 290 | 342 | }), |
| 291 | 343 | ); |
| 292 | 344 | expect( |
| 293 | selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) | |
| 294 | .runningTerminalIds, | |
| 345 | selectThreadTerminalState( | |
| 346 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 347 | THREAD_REF, | |
| 348 | ).runningTerminalIds, | |
| 295 | 349 | ).toEqual(["terminal-2"]); |
| 296 | 350 | |
| 297 | 351 | store.applyTerminalEvent( |
| 352 | THREAD_REF, | |
| 298 | 353 | makeTerminalEvent("exited", { |
| 299 | 354 | terminalId: "terminal-2", |
| 300 | 355 | exitCode: 0, |
| ... | ... | @@ -303,12 +358,12 @@ describe("terminalStateStore actions", () => { |
| 303 | 358 | ); |
| 304 | 359 | |
| 305 | 360 | const terminalState = selectThreadTerminalState( |
| 306 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 307 | THREAD_ID, | |
| 361 | useTerminalStateStore.getState().terminalStateByThreadKey, | |
| 362 | THREAD_REF, | |
| 308 | 363 | ); |
| 309 | 364 | const entries = selectTerminalEventEntries( |
| 310 | 365 | useTerminalStateStore.getState().terminalEventEntriesByKey, |
| 311 | THREAD_ID, | |
| 366 | THREAD_REF, | |
| 312 | 367 | "terminal-2", |
| 313 | 368 | ); |
| 314 | 369 | |
| ... | ... | @@ -318,12 +373,12 @@ describe("terminalStateStore actions", () => { |
| 318 | 373 | |
| 319 | 374 | it("clears buffered terminal events when a thread terminal state is removed", () => { |
| 320 | 375 | const store = useTerminalStateStore.getState(); |
| 321 | store.recordTerminalEvent(makeTerminalEvent("output")); | |
| 322 | store.removeTerminalState(THREAD_ID); | |
| 376 | store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output")); | |
| 377 | store.removeTerminalState(THREAD_REF); | |
| 323 | 378 | |
| 324 | 379 | const entries = selectTerminalEventEntries( |
| 325 | 380 | useTerminalStateStore.getState().terminalEventEntriesByKey, |
| 326 | THREAD_ID, | |
| 381 | THREAD_REF, | |
| 327 | 382 | "default", |
| 328 | 383 | ); |
| 329 | 384 | |
| ... | ... | @@ -334,7 +389,7 @@ describe("terminalStateStore actions", () => { |
| 334 | 389 | const store = useTerminalStateStore.getState(); |
| 335 | 390 | const before = useTerminalStateStore.getState(); |
| 336 | 391 | |
| 337 | store.clearTerminalState(THREAD_ID); | |
| 392 | store.clearTerminalState(THREAD_REF); | |
| 338 | 393 | |
| 339 | 394 | expect(useTerminalStateStore.getState()).toBe(before); |
| 340 | 395 | }); |
apps/web/src/terminalStateStore.ts+165-127| ... | ... | @@ -1,11 +1,12 @@ |
| 1 | 1 | /** |
| 2 | * Single Zustand store for terminal UI state keyed by threadId. | |
| 2 | * Single Zustand store for terminal UI state keyed by scoped thread identity. | |
| 3 | 3 | * |
| 4 | 4 | * Terminal transition helpers are intentionally private to keep the public |
| 5 | 5 | * API constrained to store actions/selectors. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; | |
| 8 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; | |
| 9 | import { type ScopedThreadRef, type TerminalEvent } from "@t3tools/contracts"; | |
| 9 | 10 | import { create } from "zustand"; |
| 10 | 11 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 11 | 12 | import { resolveStorage } from "./lib/storage"; |
| ... | ... | @@ -41,6 +42,26 @@ const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; |
| 41 | 42 | const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = []; |
| 42 | 43 | const MAX_TERMINAL_EVENT_BUFFER = 200; |
| 43 | 44 | |
| 45 | interface PersistedTerminalStateStoreState { | |
| 46 | terminalStateByThreadKey?: Record<string, ThreadTerminalState>; | |
| 47 | } | |
| 48 | ||
| 49 | export function migratePersistedTerminalStateStoreState( | |
| 50 | persistedState: unknown, | |
| 51 | version: number, | |
| 52 | ): PersistedTerminalStateStoreState { | |
| 53 | if (version === 1 && persistedState && typeof persistedState === "object") { | |
| 54 | const candidate = persistedState as PersistedTerminalStateStoreState; | |
| 55 | const nextTerminalStateByThreadKey = Object.fromEntries( | |
| 56 | Object.entries(candidate.terminalStateByThreadKey ?? {}).filter(([threadKey]) => | |
| 57 | parseScopedThreadKey(threadKey), | |
| 58 | ), | |
| 59 | ); | |
| 60 | return { terminalStateByThreadKey: nextTerminalStateByThreadKey }; | |
| 61 | } | |
| 62 | return { terminalStateByThreadKey: {} }; | |
| 63 | } | |
| 64 | ||
| 44 | 65 | function createTerminalStateStorage() { |
| 45 | 66 | return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); |
| 46 | 67 | } |
| ... | ... | @@ -240,8 +261,12 @@ function isValidTerminalId(terminalId: string): boolean { |
| 240 | 261 | return terminalId.trim().length > 0; |
| 241 | 262 | } |
| 242 | 263 | |
| 243 | function terminalEventBufferKey(threadId: ThreadId, terminalId: string): string { | |
| 244 | return `${threadId}\u0000${terminalId}`; | |
| 264 | function terminalThreadKey(threadRef: ScopedThreadRef): string { | |
| 265 | return scopedThreadKey(threadRef); | |
| 266 | } | |
| 267 | ||
| 268 | function terminalEventBufferKey(threadRef: ScopedThreadRef, terminalId: string): string { | |
| 269 | return `${terminalThreadKey(threadRef)}\u0000${terminalId}`; | |
| 245 | 270 | } |
| 246 | 271 | |
| 247 | 272 | function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { |
| ... | ... | @@ -254,9 +279,10 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ |
| 254 | 279 | function appendTerminalEventEntry( |
| 255 | 280 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>, |
| 256 | 281 | nextTerminalEventId: number, |
| 282 | threadRef: ScopedThreadRef, | |
| 257 | 283 | event: TerminalEvent, |
| 258 | 284 | ) { |
| 259 | const key = terminalEventBufferKey(ThreadId.makeUnsafe(event.threadId), event.terminalId); | |
| 285 | const key = terminalEventBufferKey(threadRef, event.terminalId); | |
| 260 | 286 | const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; |
| 261 | 287 | const nextEntry: TerminalEventEntry = { |
| 262 | 288 | id: nextTerminalEventId, |
| ... | ... | @@ -484,125 +510,129 @@ function setThreadTerminalActivity( |
| 484 | 510 | } |
| 485 | 511 | |
| 486 | 512 | export function selectThreadTerminalState( |
| 487 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>, | |
| 488 | threadId: ThreadId, | |
| 513 | terminalStateByThreadKey: Record<string, ThreadTerminalState>, | |
| 514 | threadRef: ScopedThreadRef | null | undefined, | |
| 489 | 515 | ): ThreadTerminalState { |
| 490 | if (threadId.length === 0) { | |
| 516 | if (!threadRef || threadRef.threadId.length === 0) { | |
| 491 | 517 | return getDefaultThreadTerminalState(); |
| 492 | 518 | } |
| 493 | return terminalStateByThreadId[threadId] ?? getDefaultThreadTerminalState(); | |
| 519 | return terminalStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalState(); | |
| 494 | 520 | } |
| 495 | 521 | |
| 496 | function updateTerminalStateByThreadId( | |
| 497 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>, | |
| 498 | threadId: ThreadId, | |
| 522 | function updateTerminalStateByThreadKey( | |
| 523 | terminalStateByThreadKey: Record<string, ThreadTerminalState>, | |
| 524 | threadRef: ScopedThreadRef, | |
| 499 | 525 | updater: (state: ThreadTerminalState) => ThreadTerminalState, |
| 500 | ): Record<ThreadId, ThreadTerminalState> { | |
| 501 | if (threadId.length === 0) { | |
| 502 | return terminalStateByThreadId; | |
| 526 | ): Record<string, ThreadTerminalState> { | |
| 527 | if (threadRef.threadId.length === 0) { | |
| 528 | return terminalStateByThreadKey; | |
| 503 | 529 | } |
| 504 | 530 | |
| 505 | const current = selectThreadTerminalState(terminalStateByThreadId, threadId); | |
| 531 | const threadKey = terminalThreadKey(threadRef); | |
| 532 | const current = selectThreadTerminalState(terminalStateByThreadKey, threadRef); | |
| 506 | 533 | const next = updater(current); |
| 507 | 534 | if (next === current) { |
| 508 | return terminalStateByThreadId; | |
| 535 | return terminalStateByThreadKey; | |
| 509 | 536 | } |
| 510 | 537 | |
| 511 | 538 | if (isDefaultThreadTerminalState(next)) { |
| 512 | if (terminalStateByThreadId[threadId] === undefined) { | |
| 513 | return terminalStateByThreadId; | |
| 539 | if (terminalStateByThreadKey[threadKey] === undefined) { | |
| 540 | return terminalStateByThreadKey; | |
| 514 | 541 | } |
| 515 | const { [threadId]: _removed, ...rest } = terminalStateByThreadId; | |
| 516 | return rest as Record<ThreadId, ThreadTerminalState>; | |
| 542 | const { [threadKey]: _removed, ...rest } = terminalStateByThreadKey; | |
| 543 | return rest; | |
| 517 | 544 | } |
| 518 | 545 | |
| 519 | 546 | return { |
| 520 | ...terminalStateByThreadId, | |
| 521 | [threadId]: next, | |
| 547 | ...terminalStateByThreadKey, | |
| 548 | [threadKey]: next, | |
| 522 | 549 | }; |
| 523 | 550 | } |
| 524 | 551 | |
| 525 | 552 | export function selectTerminalEventEntries( |
| 526 | 553 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>, |
| 527 | threadId: ThreadId, | |
| 554 | threadRef: ScopedThreadRef | null | undefined, | |
| 528 | 555 | terminalId: string, |
| 529 | 556 | ): ReadonlyArray<TerminalEventEntry> { |
| 530 | if (threadId.length === 0 || terminalId.trim().length === 0) { | |
| 557 | if (!threadRef || threadRef.threadId.length === 0 || terminalId.trim().length === 0) { | |
| 531 | 558 | return EMPTY_TERMINAL_EVENT_ENTRIES; |
| 532 | 559 | } |
| 533 | 560 | return ( |
| 534 | terminalEventEntriesByKey[terminalEventBufferKey(threadId, terminalId)] ?? | |
| 561 | terminalEventEntriesByKey[terminalEventBufferKey(threadRef, terminalId)] ?? | |
| 535 | 562 | EMPTY_TERMINAL_EVENT_ENTRIES |
| 536 | 563 | ); |
| 537 | 564 | } |
| 538 | 565 | |
| 539 | 566 | interface TerminalStateStoreState { |
| 540 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>; | |
| 541 | terminalLaunchContextByThreadId: Record<ThreadId, ThreadTerminalLaunchContext>; | |
| 567 | terminalStateByThreadKey: Record<string, ThreadTerminalState>; | |
| 568 | terminalLaunchContextByThreadKey: Record<string, ThreadTerminalLaunchContext>; | |
| 542 | 569 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>; |
| 543 | 570 | nextTerminalEventId: number; |
| 544 | setTerminalOpen: (threadId: ThreadId, open: boolean) => void; | |
| 545 | setTerminalHeight: (threadId: ThreadId, height: number) => void; | |
| 546 | splitTerminal: (threadId: ThreadId, terminalId: string) => void; | |
| 547 | newTerminal: (threadId: ThreadId, terminalId: string) => void; | |
| 571 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; | |
| 572 | setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; | |
| 573 | splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | |
| 574 | newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | |
| 548 | 575 | ensureTerminal: ( |
| 549 | threadId: ThreadId, | |
| 576 | threadRef: ScopedThreadRef, | |
| 550 | 577 | terminalId: string, |
| 551 | 578 | options?: { open?: boolean; active?: boolean }, |
| 552 | 579 | ) => void; |
| 553 | setActiveTerminal: (threadId: ThreadId, terminalId: string) => void; | |
| 554 | closeTerminal: (threadId: ThreadId, terminalId: string) => void; | |
| 555 | setTerminalLaunchContext: (threadId: ThreadId, context: ThreadTerminalLaunchContext) => void; | |
| 556 | clearTerminalLaunchContext: (threadId: ThreadId) => void; | |
| 580 | setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | |
| 581 | closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | |
| 582 | setTerminalLaunchContext: ( | |
| 583 | threadRef: ScopedThreadRef, | |
| 584 | context: ThreadTerminalLaunchContext, | |
| 585 | ) => void; | |
| 586 | clearTerminalLaunchContext: (threadRef: ScopedThreadRef) => void; | |
| 557 | 587 | setTerminalActivity: ( |
| 558 | threadId: ThreadId, | |
| 588 | threadRef: ScopedThreadRef, | |
| 559 | 589 | terminalId: string, |
| 560 | 590 | hasRunningSubprocess: boolean, |
| 561 | 591 | ) => void; |
| 562 | recordTerminalEvent: (event: TerminalEvent) => void; | |
| 563 | applyTerminalEvent: (event: TerminalEvent) => void; | |
| 564 | clearTerminalState: (threadId: ThreadId) => void; | |
| 565 | removeTerminalState: (threadId: ThreadId) => void; | |
| 566 | removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void; | |
| 592 | recordTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; | |
| 593 | applyTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; | |
| 594 | clearTerminalState: (threadRef: ScopedThreadRef) => void; | |
| 595 | removeTerminalState: (threadRef: ScopedThreadRef) => void; | |
| 596 | removeOrphanedTerminalStates: (activeThreadKeys: Set<string>) => void; | |
| 567 | 597 | } |
| 568 | 598 | |
| 569 | 599 | export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 570 | 600 | persist( |
| 571 | 601 | (set) => { |
| 572 | 602 | const updateTerminal = ( |
| 573 | threadId: ThreadId, | |
| 603 | threadRef: ScopedThreadRef, | |
| 574 | 604 | updater: (state: ThreadTerminalState) => ThreadTerminalState, |
| 575 | 605 | ) => { |
| 576 | 606 | set((state) => { |
| 577 | const nextTerminalStateByThreadId = updateTerminalStateByThreadId( | |
| 578 | state.terminalStateByThreadId, | |
| 579 | threadId, | |
| 607 | const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( | |
| 608 | state.terminalStateByThreadKey, | |
| 609 | threadRef, | |
| 580 | 610 | updater, |
| 581 | 611 | ); |
| 582 | if (nextTerminalStateByThreadId === state.terminalStateByThreadId) { | |
| 612 | if (nextTerminalStateByThreadKey === state.terminalStateByThreadKey) { | |
| 583 | 613 | return state; |
| 584 | 614 | } |
| 585 | 615 | return { |
| 586 | terminalStateByThreadId: nextTerminalStateByThreadId, | |
| 616 | terminalStateByThreadKey: nextTerminalStateByThreadKey, | |
| 587 | 617 | }; |
| 588 | 618 | }); |
| 589 | 619 | }; |
| 590 | 620 | |
| 591 | 621 | return { |
| 592 | terminalStateByThreadId: {}, | |
| 593 | terminalLaunchContextByThreadId: {}, | |
| 622 | terminalStateByThreadKey: {}, | |
| 623 | terminalLaunchContextByThreadKey: {}, | |
| 594 | 624 | terminalEventEntriesByKey: {}, |
| 595 | 625 | nextTerminalEventId: 1, |
| 596 | setTerminalOpen: (threadId, open) => | |
| 597 | updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), | |
| 598 | setTerminalHeight: (threadId, height) => | |
| 599 | updateTerminal(threadId, (state) => setThreadTerminalHeight(state, height)), | |
| 600 | splitTerminal: (threadId, terminalId) => | |
| 601 | updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)), | |
| 602 | newTerminal: (threadId, terminalId) => | |
| 603 | updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)), | |
| 604 | ensureTerminal: (threadId, terminalId, options) => | |
| 605 | updateTerminal(threadId, (state) => { | |
| 626 | setTerminalOpen: (threadRef, open) => | |
| 627 | updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)), | |
| 628 | setTerminalHeight: (threadRef, height) => | |
| 629 | updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), | |
| 630 | splitTerminal: (threadRef, terminalId) => | |
| 631 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), | |
| 632 | newTerminal: (threadRef, terminalId) => | |
| 633 | updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), | |
| 634 | ensureTerminal: (threadRef, terminalId, options) => | |
| 635 | updateTerminal(threadRef, (state) => { | |
| 606 | 636 | let nextState = state; |
| 607 | 637 | if (!state.terminalIds.includes(terminalId)) { |
| 608 | 638 | nextState = newThreadTerminal(nextState, terminalId); |
| ... | ... | @@ -622,47 +652,49 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 622 | 652 | } |
| 623 | 653 | return normalizeThreadTerminalState(nextState); |
| 624 | 654 | }), |
| 625 | setActiveTerminal: (threadId, terminalId) => | |
| 626 | updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)), | |
| 627 | closeTerminal: (threadId, terminalId) => | |
| 628 | updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)), | |
| 629 | setTerminalLaunchContext: (threadId, context) => | |
| 655 | setActiveTerminal: (threadRef, terminalId) => | |
| 656 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), | |
| 657 | closeTerminal: (threadRef, terminalId) => | |
| 658 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), | |
| 659 | setTerminalLaunchContext: (threadRef, context) => | |
| 630 | 660 | set((state) => ({ |
| 631 | terminalLaunchContextByThreadId: { | |
| 632 | ...state.terminalLaunchContextByThreadId, | |
| 633 | [threadId]: context, | |
| 661 | terminalLaunchContextByThreadKey: { | |
| 662 | ...state.terminalLaunchContextByThreadKey, | |
| 663 | [terminalThreadKey(threadRef)]: context, | |
| 634 | 664 | }, |
| 635 | 665 | })), |
| 636 | clearTerminalLaunchContext: (threadId) => | |
| 666 | clearTerminalLaunchContext: (threadRef) => | |
| 637 | 667 | set((state) => { |
| 638 | if (!state.terminalLaunchContextByThreadId[threadId]) { | |
| 668 | const threadKey = terminalThreadKey(threadRef); | |
| 669 | if (!state.terminalLaunchContextByThreadKey[threadKey]) { | |
| 639 | 670 | return state; |
| 640 | 671 | } |
| 641 | const { [threadId]: _removed, ...rest } = state.terminalLaunchContextByThreadId; | |
| 642 | return { terminalLaunchContextByThreadId: rest }; | |
| 672 | const { [threadKey]: _removed, ...rest } = state.terminalLaunchContextByThreadKey; | |
| 673 | return { terminalLaunchContextByThreadKey: rest }; | |
| 643 | 674 | }), |
| 644 | setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => | |
| 645 | updateTerminal(threadId, (state) => | |
| 675 | setTerminalActivity: (threadRef, terminalId, hasRunningSubprocess) => | |
| 676 | updateTerminal(threadRef, (state) => | |
| 646 | 677 | setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), |
| 647 | 678 | ), |
| 648 | recordTerminalEvent: (event) => | |
| 679 | recordTerminalEvent: (threadRef, event) => | |
| 649 | 680 | set((state) => |
| 650 | 681 | appendTerminalEventEntry( |
| 651 | 682 | state.terminalEventEntriesByKey, |
| 652 | 683 | state.nextTerminalEventId, |
| 684 | threadRef, | |
| 653 | 685 | event, |
| 654 | 686 | ), |
| 655 | 687 | ), |
| 656 | applyTerminalEvent: (event) => | |
| 688 | applyTerminalEvent: (threadRef, event) => | |
| 657 | 689 | set((state) => { |
| 658 | const threadId = ThreadId.makeUnsafe(event.threadId); | |
| 659 | let nextTerminalStateByThreadId = state.terminalStateByThreadId; | |
| 660 | let nextTerminalLaunchContextByThreadId = state.terminalLaunchContextByThreadId; | |
| 690 | const threadKey = terminalThreadKey(threadRef); | |
| 691 | let nextTerminalStateByThreadKey = state.terminalStateByThreadKey; | |
| 692 | let nextTerminalLaunchContextByThreadKey = state.terminalLaunchContextByThreadKey; | |
| 661 | 693 | |
| 662 | 694 | if (event.type === "started" || event.type === "restarted") { |
| 663 | nextTerminalStateByThreadId = updateTerminalStateByThreadId( | |
| 664 | nextTerminalStateByThreadId, | |
| 665 | threadId, | |
| 695 | nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( | |
| 696 | nextTerminalStateByThreadKey, | |
| 697 | threadRef, | |
| 666 | 698 | (current) => { |
| 667 | 699 | let nextState = current; |
| 668 | 700 | if (!current.terminalIds.includes(event.terminalId)) { |
| ... | ... | @@ -673,17 +705,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 673 | 705 | return normalizeThreadTerminalState(nextState); |
| 674 | 706 | }, |
| 675 | 707 | ); |
| 676 | nextTerminalLaunchContextByThreadId = { | |
| 677 | ...nextTerminalLaunchContextByThreadId, | |
| 678 | [threadId]: launchContextFromStartEvent(event), | |
| 708 | nextTerminalLaunchContextByThreadKey = { | |
| 709 | ...nextTerminalLaunchContextByThreadKey, | |
| 710 | [threadKey]: launchContextFromStartEvent(event), | |
| 679 | 711 | }; |
| 680 | 712 | } |
| 681 | 713 | |
| 682 | 714 | const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); |
| 683 | 715 | if (hasRunningSubprocess !== null) { |
| 684 | nextTerminalStateByThreadId = updateTerminalStateByThreadId( | |
| 685 | nextTerminalStateByThreadId, | |
| 686 | threadId, | |
| 716 | nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( | |
| 717 | nextTerminalStateByThreadKey, | |
| 718 | threadRef, | |
| 687 | 719 | (current) => |
| 688 | 720 | setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess), |
| 689 | 721 | ); |
| ... | ... | @@ -692,54 +724,59 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 692 | 724 | const nextEventState = appendTerminalEventEntry( |
| 693 | 725 | state.terminalEventEntriesByKey, |
| 694 | 726 | state.nextTerminalEventId, |
| 727 | threadRef, | |
| 695 | 728 | event, |
| 696 | 729 | ); |
| 697 | 730 | |
| 698 | 731 | return { |
| 699 | terminalStateByThreadId: nextTerminalStateByThreadId, | |
| 700 | terminalLaunchContextByThreadId: nextTerminalLaunchContextByThreadId, | |
| 732 | terminalStateByThreadKey: nextTerminalStateByThreadKey, | |
| 733 | terminalLaunchContextByThreadKey: nextTerminalLaunchContextByThreadKey, | |
| 701 | 734 | ...nextEventState, |
| 702 | 735 | }; |
| 703 | 736 | }), |
| 704 | clearTerminalState: (threadId) => | |
| 737 | clearTerminalState: (threadRef) => | |
| 705 | 738 | set((state) => { |
| 706 | const nextTerminalStateByThreadId = updateTerminalStateByThreadId( | |
| 707 | state.terminalStateByThreadId, | |
| 708 | threadId, | |
| 739 | const threadKey = terminalThreadKey(threadRef); | |
| 740 | const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( | |
| 741 | state.terminalStateByThreadKey, | |
| 742 | threadRef, | |
| 709 | 743 | () => createDefaultThreadTerminalState(), |
| 710 | 744 | ); |
| 711 | const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; | |
| 712 | const { [threadId]: _removed, ...remainingLaunchContexts } = | |
| 713 | state.terminalLaunchContextByThreadId; | |
| 745 | const hadLaunchContext = | |
| 746 | state.terminalLaunchContextByThreadKey[threadKey] !== undefined; | |
| 747 | const { [threadKey]: _removed, ...remainingLaunchContexts } = | |
| 748 | state.terminalLaunchContextByThreadKey; | |
| 714 | 749 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; |
| 715 | 750 | let removedEventEntries = false; |
| 716 | 751 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { |
| 717 | if (key.startsWith(`${threadId}\u0000`)) { | |
| 752 | if (key.startsWith(`${threadKey}\u0000`)) { | |
| 718 | 753 | delete nextTerminalEventEntriesByKey[key]; |
| 719 | 754 | removedEventEntries = true; |
| 720 | 755 | } |
| 721 | 756 | } |
| 722 | 757 | if ( |
| 723 | nextTerminalStateByThreadId === state.terminalStateByThreadId && | |
| 758 | nextTerminalStateByThreadKey === state.terminalStateByThreadKey && | |
| 724 | 759 | !hadLaunchContext && |
| 725 | 760 | !removedEventEntries |
| 726 | 761 | ) { |
| 727 | 762 | return state; |
| 728 | 763 | } |
| 729 | 764 | return { |
| 730 | terminalStateByThreadId: nextTerminalStateByThreadId, | |
| 731 | terminalLaunchContextByThreadId: remainingLaunchContexts, | |
| 765 | terminalStateByThreadKey: nextTerminalStateByThreadKey, | |
| 766 | terminalLaunchContextByThreadKey: remainingLaunchContexts, | |
| 732 | 767 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, |
| 733 | 768 | }; |
| 734 | 769 | }), |
| 735 | removeTerminalState: (threadId) => | |
| 770 | removeTerminalState: (threadRef) => | |
| 736 | 771 | set((state) => { |
| 737 | const hadTerminalState = state.terminalStateByThreadId[threadId] !== undefined; | |
| 738 | const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; | |
| 772 | const threadKey = terminalThreadKey(threadRef); | |
| 773 | const hadTerminalState = state.terminalStateByThreadKey[threadKey] !== undefined; | |
| 774 | const hadLaunchContext = | |
| 775 | state.terminalLaunchContextByThreadKey[threadKey] !== undefined; | |
| 739 | 776 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; |
| 740 | 777 | let removedEventEntries = false; |
| 741 | 778 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { |
| 742 | if (key.startsWith(`${threadId}\u0000`)) { | |
| 779 | if (key.startsWith(`${threadKey}\u0000`)) { | |
| 743 | 780 | delete nextTerminalEventEntriesByKey[key]; |
| 744 | 781 | removedEventEntries = true; |
| 745 | 782 | } |
| ... | ... | @@ -747,29 +784,29 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 747 | 784 | if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) { |
| 748 | 785 | return state; |
| 749 | 786 | } |
| 750 | const nextTerminalStateByThreadId = { ...state.terminalStateByThreadId }; | |
| 751 | delete nextTerminalStateByThreadId[threadId]; | |
| 752 | const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId }; | |
| 753 | delete nextLaunchContexts[threadId]; | |
| 787 | const nextTerminalStateByThreadKey = { ...state.terminalStateByThreadKey }; | |
| 788 | delete nextTerminalStateByThreadKey[threadKey]; | |
| 789 | const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey }; | |
| 790 | delete nextLaunchContexts[threadKey]; | |
| 754 | 791 | return { |
| 755 | terminalStateByThreadId: nextTerminalStateByThreadId, | |
| 756 | terminalLaunchContextByThreadId: nextLaunchContexts, | |
| 792 | terminalStateByThreadKey: nextTerminalStateByThreadKey, | |
| 793 | terminalLaunchContextByThreadKey: nextLaunchContexts, | |
| 757 | 794 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, |
| 758 | 795 | }; |
| 759 | 796 | }), |
| 760 | removeOrphanedTerminalStates: (activeThreadIds) => | |
| 797 | removeOrphanedTerminalStates: (activeThreadKeys) => | |
| 761 | 798 | set((state) => { |
| 762 | const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( | |
| 763 | (id) => !activeThreadIds.has(id as ThreadId), | |
| 799 | const orphanedIds = Object.keys(state.terminalStateByThreadKey).filter( | |
| 800 | (key) => !activeThreadKeys.has(key), | |
| 764 | 801 | ); |
| 765 | 802 | const orphanedLaunchContextIds = Object.keys( |
| 766 | state.terminalLaunchContextByThreadId, | |
| 767 | ).filter((id) => !activeThreadIds.has(id as ThreadId)); | |
| 803 | state.terminalLaunchContextByThreadKey, | |
| 804 | ).filter((key) => !activeThreadKeys.has(key)); | |
| 768 | 805 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; |
| 769 | 806 | let removedEventEntries = false; |
| 770 | 807 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { |
| 771 | const [threadId] = key.split("\u0000"); | |
| 772 | if (threadId && !activeThreadIds.has(threadId as ThreadId)) { | |
| 808 | const [threadKey] = key.split("\u0000"); | |
| 809 | if (threadKey && !activeThreadKeys.has(threadKey)) { | |
| 773 | 810 | delete nextTerminalEventEntriesByKey[key]; |
| 774 | 811 | removedEventEntries = true; |
| 775 | 812 | } |
| ... | ... | @@ -781,17 +818,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 781 | 818 | ) { |
| 782 | 819 | return state; |
| 783 | 820 | } |
| 784 | const next = { ...state.terminalStateByThreadId }; | |
| 821 | const next = { ...state.terminalStateByThreadKey }; | |
| 785 | 822 | for (const id of orphanedIds) { |
| 786 | delete next[id as ThreadId]; | |
| 823 | delete next[id]; | |
| 787 | 824 | } |
| 788 | const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId }; | |
| 825 | const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey }; | |
| 789 | 826 | for (const id of orphanedLaunchContextIds) { |
| 790 | delete nextLaunchContexts[id as ThreadId]; | |
| 827 | delete nextLaunchContexts[id]; | |
| 791 | 828 | } |
| 792 | 829 | return { |
| 793 | terminalStateByThreadId: next, | |
| 794 | terminalLaunchContextByThreadId: nextLaunchContexts, | |
| 830 | terminalStateByThreadKey: next, | |
| 831 | terminalLaunchContextByThreadKey: nextLaunchContexts, | |
| 795 | 832 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, |
| 796 | 833 | }; |
| 797 | 834 | }), |
| ... | ... | @@ -799,10 +836,11 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 799 | 836 | }, |
| 800 | 837 | { |
| 801 | 838 | name: TERMINAL_STATE_STORAGE_KEY, |
| 802 | version: 1, | |
| 839 | version: 2, | |
| 803 | 840 | storage: createJSONStorage(createTerminalStateStorage), |
| 841 | migrate: migratePersistedTerminalStateStoreState, | |
| 804 | 842 | partialize: (state) => ({ |
| 805 | terminalStateByThreadId: state.terminalStateByThreadId, | |
| 843 | terminalStateByThreadKey: state.terminalStateByThreadKey, | |
| 806 | 844 | }), |
| 807 | 845 | }, |
| 808 | 846 | ), |
apps/web/src/threadRoutes.test.ts created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import { scopeThreadRef } from "@t3tools/client-runtime"; | |
| 3 | import { ThreadId } from "@t3tools/contracts"; | |
| 4 | import { DraftId } from "./composerDraftStore"; | |
| 5 | ||
| 6 | import { | |
| 7 | buildDraftThreadRouteParams, | |
| 8 | buildThreadRouteParams, | |
| 9 | resolveThreadRouteRef, | |
| 10 | resolveThreadRouteTarget, | |
| 11 | } from "./threadRoutes"; | |
| 12 | ||
| 13 | describe("threadRoutes", () => { | |
| 14 | it("builds canonical thread route params from a scoped ref", () => { | |
| 15 | const ref = scopeThreadRef("env-1" as never, ThreadId.makeUnsafe("thread-1")); | |
| 16 | ||
| 17 | expect(buildThreadRouteParams(ref)).toEqual({ | |
| 18 | environmentId: "env-1", | |
| 19 | threadId: "thread-1", | |
| 20 | }); | |
| 21 | }); | |
| 22 | ||
| 23 | it("resolves a scoped ref only when both params are present", () => { | |
| 24 | expect( | |
| 25 | resolveThreadRouteRef({ | |
| 26 | environmentId: "env-1", | |
| 27 | threadId: "thread-1", | |
| 28 | }), | |
| 29 | ).toEqual({ | |
| 30 | environmentId: "env-1", | |
| 31 | threadId: "thread-1", | |
| 32 | }); | |
| 33 | ||
| 34 | expect(resolveThreadRouteRef({ environmentId: "env-1" })).toBeNull(); | |
| 35 | expect(resolveThreadRouteRef({ threadId: "thread-1" })).toBeNull(); | |
| 36 | }); | |
| 37 | ||
| 38 | it("builds canonical draft route params from a draft id", () => { | |
| 39 | expect(buildDraftThreadRouteParams(DraftId.makeUnsafe("draft-1"))).toEqual({ | |
| 40 | draftId: "draft-1", | |
| 41 | }); | |
| 42 | }); | |
| 43 | ||
| 44 | it("resolves draft and server route targets", () => { | |
| 45 | expect( | |
| 46 | resolveThreadRouteTarget({ | |
| 47 | environmentId: "env-1", | |
| 48 | threadId: "thread-1", | |
| 49 | }), | |
| 50 | ).toEqual({ | |
| 51 | kind: "server", | |
| 52 | threadRef: { | |
| 53 | environmentId: "env-1", | |
| 54 | threadId: "thread-1", | |
| 55 | }, | |
| 56 | }); | |
| 57 | ||
| 58 | expect( | |
| 59 | resolveThreadRouteTarget({ | |
| 60 | draftId: "draft-1", | |
| 61 | }), | |
| 62 | ).toEqual({ | |
| 63 | kind: "draft", | |
| 64 | draftId: "draft-1", | |
| 65 | }); | |
| 66 | }); | |
| 67 | }); |
apps/web/src/threadRoutes.ts created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | |
| 2 | import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | |
| 3 | import type { DraftId } from "./composerDraftStore"; | |
| 4 | ||
| 5 | export type ThreadRouteTarget = | |
| 6 | | { | |
| 7 | kind: "server"; | |
| 8 | threadRef: ScopedThreadRef; | |
| 9 | } | |
| 10 | | { | |
| 11 | kind: "draft"; | |
| 12 | draftId: DraftId; | |
| 13 | }; | |
| 14 | ||
| 15 | export function buildThreadRouteParams(ref: ScopedThreadRef): { | |
| 16 | environmentId: EnvironmentId; | |
| 17 | threadId: ThreadId; | |
| 18 | } { | |
| 19 | return { | |
| 20 | environmentId: ref.environmentId, | |
| 21 | threadId: ref.threadId, | |
| 22 | }; | |
| 23 | } | |
| 24 | ||
| 25 | export function buildDraftThreadRouteParams(draftId: DraftId): { | |
| 26 | draftId: DraftId; | |
| 27 | } { | |
| 28 | return { draftId }; | |
| 29 | } | |
| 30 | ||
| 31 | export function resolveThreadRouteRef( | |
| 32 | params: Partial<Record<"environmentId" | "threadId", string | undefined>>, | |
| 33 | ): ScopedThreadRef | null { | |
| 34 | if (!params.environmentId || !params.threadId) { | |
| 35 | return null; | |
| 36 | } | |
| 37 | ||
| 38 | return scopeThreadRef(params.environmentId as EnvironmentId, params.threadId as ThreadId); | |
| 39 | } | |
| 40 | ||
| 41 | export function resolveThreadRouteTarget( | |
| 42 | params: Partial<Record<"environmentId" | "threadId" | "draftId", string | undefined>>, | |
| 43 | ): ThreadRouteTarget | null { | |
| 44 | if (params.environmentId && params.threadId) { | |
| 45 | return { | |
| 46 | kind: "server", | |
| 47 | threadRef: scopeThreadRef(params.environmentId as EnvironmentId, params.threadId as ThreadId), | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | if (!params.draftId) { | |
| 52 | return null; | |
| 53 | } | |
| 54 | ||
| 55 | return { | |
| 56 | kind: "draft", | |
| 57 | draftId: params.draftId as DraftId, | |
| 58 | }; | |
| 59 | } |
apps/web/src/threadSelectionStore.test.ts+53-53| ... | ... | @@ -21,9 +21,9 @@ describe("threadSelectionStore", () => { |
| 21 | 21 | useThreadSelectionStore.getState().toggleThread(THREAD_A); |
| 22 | 22 | |
| 23 | 23 | const state = useThreadSelectionStore.getState(); |
| 24 | expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); | |
| 25 | expect(state.selectedThreadIds.size).toBe(1); | |
| 26 | expect(state.anchorThreadId).toBe(THREAD_A); | |
| 24 | expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); | |
| 25 | expect(state.selectedThreadKeys.size).toBe(1); | |
| 26 | expect(state.anchorThreadKey).toBe(THREAD_A); | |
| 27 | 27 | }); |
| 28 | 28 | |
| 29 | 29 | it("removes a thread that is already selected", () => { |
| ... | ... | @@ -32,8 +32,8 @@ describe("threadSelectionStore", () => { |
| 32 | 32 | store.toggleThread(THREAD_A); |
| 33 | 33 | |
| 34 | 34 | const state = useThreadSelectionStore.getState(); |
| 35 | expect(state.selectedThreadIds.has(THREAD_A)).toBe(false); | |
| 36 | expect(state.selectedThreadIds.size).toBe(0); | |
| 35 | expect(state.selectedThreadKeys.has(THREAD_A)).toBe(false); | |
| 36 | expect(state.selectedThreadKeys.size).toBe(0); | |
| 37 | 37 | }); |
| 38 | 38 | |
| 39 | 39 | it("preserves existing selections when toggling a new thread", () => { |
| ... | ... | @@ -42,9 +42,9 @@ describe("threadSelectionStore", () => { |
| 42 | 42 | store.toggleThread(THREAD_B); |
| 43 | 43 | |
| 44 | 44 | const state = useThreadSelectionStore.getState(); |
| 45 | expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); | |
| 46 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | |
| 47 | expect(state.selectedThreadIds.size).toBe(2); | |
| 45 | expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); | |
| 46 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); | |
| 47 | expect(state.selectedThreadKeys.size).toBe(2); | |
| 48 | 48 | }); |
| 49 | 49 | |
| 50 | 50 | it("sets anchor to the newly added thread", () => { |
| ... | ... | @@ -52,7 +52,7 @@ describe("threadSelectionStore", () => { |
| 52 | 52 | store.toggleThread(THREAD_A); |
| 53 | 53 | store.toggleThread(THREAD_B); |
| 54 | 54 | |
| 55 | expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); | |
| 55 | expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); | |
| 56 | 56 | }); |
| 57 | 57 | |
| 58 | 58 | it("preserves anchor when deselecting a non-anchor thread", () => { |
| ... | ... | @@ -61,7 +61,7 @@ describe("threadSelectionStore", () => { |
| 61 | 61 | store.toggleThread(THREAD_B); |
| 62 | 62 | store.toggleThread(THREAD_A); // deselect A, anchor should stay B |
| 63 | 63 | |
| 64 | expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); | |
| 64 | expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); | |
| 65 | 65 | }); |
| 66 | 66 | }); |
| 67 | 67 | |
| ... | ... | @@ -70,8 +70,8 @@ describe("threadSelectionStore", () => { |
| 70 | 70 | useThreadSelectionStore.getState().setAnchor(THREAD_B); |
| 71 | 71 | |
| 72 | 72 | const state = useThreadSelectionStore.getState(); |
| 73 | expect(state.anchorThreadId).toBe(THREAD_B); | |
| 74 | expect(state.selectedThreadIds.size).toBe(0); | |
| 73 | expect(state.anchorThreadKey).toBe(THREAD_B); | |
| 74 | expect(state.selectedThreadKeys.size).toBe(0); | |
| 75 | 75 | }); |
| 76 | 76 | |
| 77 | 77 | it("enables range select from a plain-click anchor", () => { |
| ... | ... | @@ -80,10 +80,10 @@ describe("threadSelectionStore", () => { |
| 80 | 80 | store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D |
| 81 | 81 | |
| 82 | 82 | const state = useThreadSelectionStore.getState(); |
| 83 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | |
| 84 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | |
| 85 | expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); | |
| 86 | expect(state.selectedThreadIds.size).toBe(3); | |
| 83 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); | |
| 84 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); | |
| 85 | expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); | |
| 86 | expect(state.selectedThreadKeys.size).toBe(3); | |
| 87 | 87 | }); |
| 88 | 88 | |
| 89 | 89 | it("is a no-op when anchor is already set to the same thread", () => { |
| ... | ... | @@ -105,8 +105,8 @@ describe("threadSelectionStore", () => { |
| 105 | 105 | store.setAnchor(THREAD_C); |
| 106 | 106 | |
| 107 | 107 | const state = useThreadSelectionStore.getState(); |
| 108 | expect(state.anchorThreadId).toBe(THREAD_C); | |
| 109 | expect(state.selectedThreadIds.size).toBe(0); | |
| 108 | expect(state.anchorThreadKey).toBe(THREAD_C); | |
| 109 | expect(state.selectedThreadKeys.size).toBe(0); | |
| 110 | 110 | }); |
| 111 | 111 | }); |
| 112 | 112 | |
| ... | ... | @@ -115,9 +115,9 @@ describe("threadSelectionStore", () => { |
| 115 | 115 | useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED); |
| 116 | 116 | |
| 117 | 117 | const state = useThreadSelectionStore.getState(); |
| 118 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | |
| 119 | expect(state.selectedThreadIds.size).toBe(1); | |
| 120 | expect(state.anchorThreadId).toBe(THREAD_C); | |
| 118 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); | |
| 119 | expect(state.selectedThreadKeys.size).toBe(1); | |
| 120 | expect(state.anchorThreadKey).toBe(THREAD_C); | |
| 121 | 121 | }); |
| 122 | 122 | |
| 123 | 123 | it("selects range from anchor to target (forward)", () => { |
| ... | ... | @@ -126,10 +126,10 @@ describe("threadSelectionStore", () => { |
| 126 | 126 | store.rangeSelectTo(THREAD_D, ORDERED); |
| 127 | 127 | |
| 128 | 128 | const state = useThreadSelectionStore.getState(); |
| 129 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | |
| 130 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | |
| 131 | expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); | |
| 132 | expect(state.selectedThreadIds.size).toBe(3); | |
| 129 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); | |
| 130 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); | |
| 131 | expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); | |
| 132 | expect(state.selectedThreadKeys.size).toBe(3); | |
| 133 | 133 | }); |
| 134 | 134 | |
| 135 | 135 | it("selects range from anchor to target (backward)", () => { |
| ... | ... | @@ -138,10 +138,10 @@ describe("threadSelectionStore", () => { |
| 138 | 138 | store.rangeSelectTo(THREAD_B, ORDERED); |
| 139 | 139 | |
| 140 | 140 | const state = useThreadSelectionStore.getState(); |
| 141 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | |
| 142 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | |
| 143 | expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); | |
| 144 | expect(state.selectedThreadIds.size).toBe(3); | |
| 141 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); | |
| 142 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); | |
| 143 | expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); | |
| 144 | expect(state.selectedThreadKeys.size).toBe(3); | |
| 145 | 145 | }); |
| 146 | 146 | |
| 147 | 147 | it("keeps anchor stable across multiple range selects", () => { |
| ... | ... | @@ -151,11 +151,11 @@ describe("threadSelectionStore", () => { |
| 151 | 151 | store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B) |
| 152 | 152 | |
| 153 | 153 | const state = useThreadSelectionStore.getState(); |
| 154 | expect(state.anchorThreadId).toBe(THREAD_B); | |
| 155 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | |
| 156 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | |
| 157 | expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); | |
| 158 | expect(state.selectedThreadIds.has(THREAD_E)).toBe(true); | |
| 154 | expect(state.anchorThreadKey).toBe(THREAD_B); | |
| 155 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); | |
| 156 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); | |
| 157 | expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); | |
| 158 | expect(state.selectedThreadKeys.has(THREAD_E)).toBe(true); | |
| 159 | 159 | }); |
| 160 | 160 | |
| 161 | 161 | it("falls back to toggle when anchor is not in the ordered list", () => { |
| ... | ... | @@ -166,8 +166,8 @@ describe("threadSelectionStore", () => { |
| 166 | 166 | |
| 167 | 167 | const state = useThreadSelectionStore.getState(); |
| 168 | 168 | // Should have added C and reset anchor to C |
| 169 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | |
| 170 | expect(state.anchorThreadId).toBe(THREAD_C); | |
| 169 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); | |
| 170 | expect(state.anchorThreadKey).toBe(THREAD_C); | |
| 171 | 171 | }); |
| 172 | 172 | |
| 173 | 173 | it("falls back to toggle when target is not in the ordered list", () => { |
| ... | ... | @@ -177,8 +177,8 @@ describe("threadSelectionStore", () => { |
| 177 | 177 | store.rangeSelectTo(unknownThread, ORDERED); |
| 178 | 178 | |
| 179 | 179 | const state = useThreadSelectionStore.getState(); |
| 180 | expect(state.selectedThreadIds.has(unknownThread)).toBe(true); | |
| 181 | expect(state.anchorThreadId).toBe(unknownThread); | |
| 180 | expect(state.selectedThreadKeys.has(unknownThread)).toBe(true); | |
| 181 | expect(state.anchorThreadKey).toBe(unknownThread); | |
| 182 | 182 | }); |
| 183 | 183 | |
| 184 | 184 | it("selects the single thread when anchor equals target", () => { |
| ... | ... | @@ -187,8 +187,8 @@ describe("threadSelectionStore", () => { |
| 187 | 187 | store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C |
| 188 | 188 | |
| 189 | 189 | const state = useThreadSelectionStore.getState(); |
| 190 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | |
| 191 | expect(state.selectedThreadIds.size).toBe(1); | |
| 190 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); | |
| 191 | expect(state.selectedThreadKeys.size).toBe(1); | |
| 192 | 192 | }); |
| 193 | 193 | |
| 194 | 194 | it("preserves previously selected threads outside the range", () => { |
| ... | ... | @@ -200,11 +200,11 @@ describe("threadSelectionStore", () => { |
| 200 | 200 | store.rangeSelectTo(THREAD_D, ORDERED); |
| 201 | 201 | |
| 202 | 202 | const state = useThreadSelectionStore.getState(); |
| 203 | expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); | |
| 204 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | |
| 205 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | |
| 206 | expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); | |
| 207 | expect(state.selectedThreadIds.size).toBe(4); | |
| 203 | expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); | |
| 204 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); | |
| 205 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); | |
| 206 | expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); | |
| 207 | expect(state.selectedThreadKeys.size).toBe(4); | |
| 208 | 208 | }); |
| 209 | 209 | }); |
| 210 | 210 | |
| ... | ... | @@ -216,8 +216,8 @@ describe("threadSelectionStore", () => { |
| 216 | 216 | store.clearSelection(); |
| 217 | 217 | |
| 218 | 218 | const state = useThreadSelectionStore.getState(); |
| 219 | expect(state.selectedThreadIds.size).toBe(0); | |
| 220 | expect(state.anchorThreadId).toBeNull(); | |
| 219 | expect(state.selectedThreadKeys.size).toBe(0); | |
| 220 | expect(state.anchorThreadKey).toBeNull(); | |
| 221 | 221 | }); |
| 222 | 222 | |
| 223 | 223 | it("is a no-op when already empty", () => { |
| ... | ... | @@ -226,7 +226,7 @@ describe("threadSelectionStore", () => { |
| 226 | 226 | const stateAfter = useThreadSelectionStore.getState(); |
| 227 | 227 | |
| 228 | 228 | // Should be referentially the same (no unnecessary re-render) |
| 229 | expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds); | |
| 229 | expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys); | |
| 230 | 230 | }); |
| 231 | 231 | }); |
| 232 | 232 | |
| ... | ... | @@ -239,8 +239,8 @@ describe("threadSelectionStore", () => { |
| 239 | 239 | store.removeFromSelection([THREAD_A, THREAD_C]); |
| 240 | 240 | |
| 241 | 241 | const state = useThreadSelectionStore.getState(); |
| 242 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | |
| 243 | expect(state.selectedThreadIds.size).toBe(1); | |
| 242 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); | |
| 243 | expect(state.selectedThreadKeys.size).toBe(1); | |
| 244 | 244 | }); |
| 245 | 245 | |
| 246 | 246 | it("clears anchor when the anchor thread is removed", () => { |
| ... | ... | @@ -249,7 +249,7 @@ describe("threadSelectionStore", () => { |
| 249 | 249 | store.toggleThread(THREAD_B); // anchor = B |
| 250 | 250 | store.removeFromSelection([THREAD_B]); |
| 251 | 251 | |
| 252 | expect(useThreadSelectionStore.getState().anchorThreadId).toBeNull(); | |
| 252 | expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull(); | |
| 253 | 253 | }); |
| 254 | 254 | |
| 255 | 255 | it("preserves anchor when the anchor thread is not removed", () => { |
| ... | ... | @@ -258,7 +258,7 @@ describe("threadSelectionStore", () => { |
| 258 | 258 | store.toggleThread(THREAD_B); // anchor = B |
| 259 | 259 | store.removeFromSelection([THREAD_A]); |
| 260 | 260 | |
| 261 | expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); | |
| 261 | expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); | |
| 262 | 262 | }); |
| 263 | 263 | |
| 264 | 264 | it("is a no-op when none of the specified threads are selected", () => { |
| ... | ... | @@ -268,7 +268,7 @@ describe("threadSelectionStore", () => { |
| 268 | 268 | store.removeFromSelection([THREAD_B, THREAD_C]); |
| 269 | 269 | const stateAfter = useThreadSelectionStore.getState(); |
| 270 | 270 | |
| 271 | expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds); | |
| 271 | expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys); | |
| 272 | 272 | }); |
| 273 | 273 | }); |
| 274 | 274 |
apps/web/src/threadSelectionStore.ts+51-53| ... | ... | @@ -4,121 +4,119 @@ |
| 4 | 4 | * Supports Cmd/Ctrl+Click (toggle individual), Shift+Click (range select), |
| 5 | 5 | * and bulk actions on the selected set. |
| 6 | 6 | */ |
| 7 | ||
| 8 | import type { ThreadId } from "@t3tools/contracts"; | |
| 9 | 7 | import { create } from "zustand"; |
| 10 | 8 | |
| 11 | 9 | export interface ThreadSelectionState { |
| 12 | /** Currently selected thread IDs. */ | |
| 13 | selectedThreadIds: ReadonlySet<ThreadId>; | |
| 14 | /** The thread ID that anchors shift-click range selection. */ | |
| 15 | anchorThreadId: ThreadId | null; | |
| 10 | /** Currently selected scoped thread keys. */ | |
| 11 | selectedThreadKeys: ReadonlySet<string>; | |
| 12 | /** The scoped thread key that anchors shift-click range selection. */ | |
| 13 | anchorThreadKey: string | null; | |
| 16 | 14 | } |
| 17 | 15 | |
| 18 | 16 | interface ThreadSelectionStore extends ThreadSelectionState { |
| 19 | /** Toggle a single thread in the selection (Cmd/Ctrl+Click). */ | |
| 20 | toggleThread: (threadId: ThreadId) => void; | |
| 17 | /** Toggle a single scoped thread key in the selection (Cmd/Ctrl+Click). */ | |
| 18 | toggleThread: (threadKey: string) => void; | |
| 21 | 19 | /** |
| 22 | 20 | * Select a range of threads (Shift+Click). |
| 23 | * Requires the ordered list of thread IDs within the same project | |
| 21 | * Requires the ordered list of scoped thread keys within the same project | |
| 24 | 22 | * so the store can compute which threads fall between anchor and target. |
| 25 | 23 | */ |
| 26 | rangeSelectTo: (threadId: ThreadId, orderedThreadIds: readonly ThreadId[]) => void; | |
| 24 | rangeSelectTo: (threadKey: string, orderedThreadKeys: readonly string[]) => void; | |
| 27 | 25 | /** Clear all selection state. */ |
| 28 | 26 | clearSelection: () => void; |
| 29 | /** Remove specific thread IDs from the selection (e.g. after deletion). */ | |
| 30 | removeFromSelection: (threadIds: readonly ThreadId[]) => void; | |
| 27 | /** Remove specific scoped thread keys from the selection (e.g. after deletion). */ | |
| 28 | removeFromSelection: (threadKeys: readonly string[]) => void; | |
| 31 | 29 | /** Set the anchor thread without adding it to the selection (e.g. on plain-click navigate). */ |
| 32 | setAnchor: (threadId: ThreadId) => void; | |
| 30 | setAnchor: (threadKey: string) => void; | |
| 33 | 31 | /** Check if any threads are selected. */ |
| 34 | 32 | hasSelection: () => boolean; |
| 35 | 33 | } |
| 36 | 34 | |
| 37 | const EMPTY_SET = new Set<ThreadId>(); | |
| 35 | const EMPTY_SET = new Set<string>(); | |
| 38 | 36 | |
| 39 | 37 | export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({ |
| 40 | selectedThreadIds: EMPTY_SET, | |
| 41 | anchorThreadId: null, | |
| 38 | selectedThreadKeys: EMPTY_SET, | |
| 39 | anchorThreadKey: null, | |
| 42 | 40 | |
| 43 | toggleThread: (threadId) => { | |
| 41 | toggleThread: (threadKey) => { | |
| 44 | 42 | set((state) => { |
| 45 | const next = new Set(state.selectedThreadIds); | |
| 46 | if (next.has(threadId)) { | |
| 47 | next.delete(threadId); | |
| 43 | const next = new Set(state.selectedThreadKeys); | |
| 44 | if (next.has(threadKey)) { | |
| 45 | next.delete(threadKey); | |
| 48 | 46 | } else { |
| 49 | next.add(threadId); | |
| 47 | next.add(threadKey); | |
| 50 | 48 | } |
| 51 | 49 | return { |
| 52 | selectedThreadIds: next, | |
| 53 | anchorThreadId: next.has(threadId) ? threadId : state.anchorThreadId, | |
| 50 | selectedThreadKeys: next, | |
| 51 | anchorThreadKey: next.has(threadKey) ? threadKey : state.anchorThreadKey, | |
| 54 | 52 | }; |
| 55 | 53 | }); |
| 56 | 54 | }, |
| 57 | 55 | |
| 58 | rangeSelectTo: (threadId, orderedThreadIds) => { | |
| 56 | rangeSelectTo: (threadKey, orderedThreadKeys) => { | |
| 59 | 57 | set((state) => { |
| 60 | const anchor = state.anchorThreadId; | |
| 58 | const anchor = state.anchorThreadKey; | |
| 61 | 59 | if (anchor === null) { |
| 62 | 60 | // No anchor yet — treat as a single toggle |
| 63 | const next = new Set(state.selectedThreadIds); | |
| 64 | next.add(threadId); | |
| 65 | return { selectedThreadIds: next, anchorThreadId: threadId }; | |
| 61 | const next = new Set(state.selectedThreadKeys); | |
| 62 | next.add(threadKey); | |
| 63 | return { selectedThreadKeys: next, anchorThreadKey: threadKey }; | |
| 66 | 64 | } |
| 67 | 65 | |
| 68 | const anchorIndex = orderedThreadIds.indexOf(anchor); | |
| 69 | const targetIndex = orderedThreadIds.indexOf(threadId); | |
| 66 | const anchorIndex = orderedThreadKeys.indexOf(anchor); | |
| 67 | const targetIndex = orderedThreadKeys.indexOf(threadKey); | |
| 70 | 68 | if (anchorIndex === -1 || targetIndex === -1) { |
| 71 | 69 | // Anchor or target not in this list (different project?) — fallback to toggle |
| 72 | const next = new Set(state.selectedThreadIds); | |
| 73 | next.add(threadId); | |
| 74 | return { selectedThreadIds: next, anchorThreadId: threadId }; | |
| 70 | const next = new Set(state.selectedThreadKeys); | |
| 71 | next.add(threadKey); | |
| 72 | return { selectedThreadKeys: next, anchorThreadKey: threadKey }; | |
| 75 | 73 | } |
| 76 | 74 | |
| 77 | 75 | const start = Math.min(anchorIndex, targetIndex); |
| 78 | 76 | const end = Math.max(anchorIndex, targetIndex); |
| 79 | const next = new Set(state.selectedThreadIds); | |
| 77 | const next = new Set(state.selectedThreadKeys); | |
| 80 | 78 | for (let i = start; i <= end; i++) { |
| 81 | const id = orderedThreadIds[i]; | |
| 82 | if (id !== undefined) { | |
| 83 | next.add(id); | |
| 79 | const key = orderedThreadKeys[i]; | |
| 80 | if (key !== undefined) { | |
| 81 | next.add(key); | |
| 84 | 82 | } |
| 85 | 83 | } |
| 86 | 84 | // Keep anchor stable so subsequent shift-clicks extend from the same point |
| 87 | return { selectedThreadIds: next, anchorThreadId: anchor }; | |
| 85 | return { selectedThreadKeys: next, anchorThreadKey: anchor }; | |
| 88 | 86 | }); |
| 89 | 87 | }, |
| 90 | 88 | |
| 91 | 89 | clearSelection: () => { |
| 92 | 90 | const state = get(); |
| 93 | if (state.selectedThreadIds.size === 0 && state.anchorThreadId === null) return; | |
| 94 | set({ selectedThreadIds: EMPTY_SET, anchorThreadId: null }); | |
| 91 | if (state.selectedThreadKeys.size === 0 && state.anchorThreadKey === null) return; | |
| 92 | set({ selectedThreadKeys: EMPTY_SET, anchorThreadKey: null }); | |
| 95 | 93 | }, |
| 96 | 94 | |
| 97 | setAnchor: (threadId) => { | |
| 98 | if (get().anchorThreadId === threadId) return; | |
| 99 | set({ anchorThreadId: threadId }); | |
| 95 | setAnchor: (threadKey) => { | |
| 96 | if (get().anchorThreadKey === threadKey) return; | |
| 97 | set({ anchorThreadKey: threadKey }); | |
| 100 | 98 | }, |
| 101 | 99 | |
| 102 | removeFromSelection: (threadIds) => { | |
| 100 | removeFromSelection: (threadKeys) => { | |
| 103 | 101 | set((state) => { |
| 104 | const toRemove = new Set(threadIds); | |
| 102 | const toRemove = new Set(threadKeys); | |
| 105 | 103 | let changed = false; |
| 106 | const next = new Set<ThreadId>(); | |
| 107 | for (const id of state.selectedThreadIds) { | |
| 108 | if (toRemove.has(id)) { | |
| 104 | const next = new Set<string>(); | |
| 105 | for (const key of state.selectedThreadKeys) { | |
| 106 | if (toRemove.has(key)) { | |
| 109 | 107 | changed = true; |
| 110 | 108 | } else { |
| 111 | next.add(id); | |
| 109 | next.add(key); | |
| 112 | 110 | } |
| 113 | 111 | } |
| 114 | 112 | if (!changed) return state; |
| 115 | 113 | const newAnchor = |
| 116 | state.anchorThreadId !== null && toRemove.has(state.anchorThreadId) | |
| 114 | state.anchorThreadKey !== null && toRemove.has(state.anchorThreadKey) | |
| 117 | 115 | ? null |
| 118 | : state.anchorThreadId; | |
| 119 | return { selectedThreadIds: next, anchorThreadId: newAnchor }; | |
| 116 | : state.anchorThreadKey; | |
| 117 | return { selectedThreadKeys: next, anchorThreadKey: newAnchor }; | |
| 120 | 118 | }); |
| 121 | 119 | }, |
| 122 | 120 | |
| 123 | hasSelection: () => get().selectedThreadIds.size > 0, | |
| 121 | hasSelection: () => get().selectedThreadKeys.size > 0, | |
| 124 | 122 | })); |
apps/web/src/types.ts+7| ... | ... | @@ -1,7 +1,9 @@ |
| 1 | 1 | import type { |
| 2 | EnvironmentId, | |
| 2 | 3 | ModelSelection, |
| 3 | 4 | OrchestrationLatestTurn, |
| 4 | 5 | OrchestrationProposedPlanId, |
| 6 | RepositoryIdentity, | |
| 5 | 7 | OrchestrationSessionStatus, |
| 6 | 8 | OrchestrationThreadActivity, |
| 7 | 9 | ProjectScript as ContractProjectScript, |
| ... | ... | @@ -80,8 +82,10 @@ export interface TurnDiffSummary { |
| 80 | 82 | |
| 81 | 83 | export interface Project { |
| 82 | 84 | id: ProjectId; |
| 85 | environmentId: EnvironmentId; | |
| 83 | 86 | name: string; |
| 84 | 87 | cwd: string; |
| 88 | repositoryIdentity?: RepositoryIdentity | null; | |
| 85 | 89 | defaultModelSelection: ModelSelection | null; |
| 86 | 90 | createdAt?: string | undefined; |
| 87 | 91 | updatedAt?: string | undefined; |
| ... | ... | @@ -90,6 +94,7 @@ export interface Project { |
| 90 | 94 | |
| 91 | 95 | export interface Thread { |
| 92 | 96 | id: ThreadId; |
| 97 | environmentId: EnvironmentId; | |
| 93 | 98 | codexThreadId: string | null; |
| 94 | 99 | projectId: ProjectId; |
| 95 | 100 | title: string; |
| ... | ... | @@ -113,6 +118,7 @@ export interface Thread { |
| 113 | 118 | |
| 114 | 119 | export interface ThreadShell { |
| 115 | 120 | id: ThreadId; |
| 121 | environmentId: EnvironmentId; | |
| 116 | 122 | codexThreadId: string | null; |
| 117 | 123 | projectId: ProjectId; |
| 118 | 124 | title: string; |
| ... | ... | @@ -134,6 +140,7 @@ export interface ThreadTurnState { |
| 134 | 140 | |
| 135 | 141 | export interface SidebarThreadSummary { |
| 136 | 142 | id: ThreadId; |
| 143 | environmentId: EnvironmentId; | |
| 137 | 144 | projectId: ProjectId; |
| 138 | 145 | title: string; |
| 139 | 146 | interactionMode: ProviderInteractionMode; |
apps/web/src/uiStateStore.test.ts+11-11| ... | ... | @@ -74,9 +74,9 @@ describe("uiStateStore pure functions", () => { |
| 74 | 74 | }); |
| 75 | 75 | |
| 76 | 76 | const next = syncProjects(initialState, [ |
| 77 | { id: project1, cwd: "/tmp/project-1" }, | |
| 78 | { id: project2, cwd: "/tmp/project-2" }, | |
| 79 | { id: project3, cwd: "/tmp/project-3" }, | |
| 77 | { key: project1, cwd: "/tmp/project-1" }, | |
| 78 | { key: project2, cwd: "/tmp/project-2" }, | |
| 79 | { key: project3, cwd: "/tmp/project-3" }, | |
| 80 | 80 | ]); |
| 81 | 81 | |
| 82 | 82 | expect(next.projectOrder).toEqual([project2, project1, project3]); |
| ... | ... | @@ -96,14 +96,14 @@ describe("uiStateStore pure functions", () => { |
| 96 | 96 | projectOrder: [oldProject2, oldProject1], |
| 97 | 97 | }), |
| 98 | 98 | [ |
| 99 | { id: oldProject1, cwd: "/tmp/project-1" }, | |
| 100 | { id: oldProject2, cwd: "/tmp/project-2" }, | |
| 99 | { key: oldProject1, cwd: "/tmp/project-1" }, | |
| 100 | { key: oldProject2, cwd: "/tmp/project-2" }, | |
| 101 | 101 | ], |
| 102 | 102 | ); |
| 103 | 103 | |
| 104 | 104 | const next = syncProjects(initialState, [ |
| 105 | { id: oldProject1, cwd: "/tmp/project-1" }, | |
| 106 | { id: recreatedProject2, cwd: "/tmp/project-2" }, | |
| 105 | { key: oldProject1, cwd: "/tmp/project-1" }, | |
| 106 | { key: recreatedProject2, cwd: "/tmp/project-2" }, | |
| 107 | 107 | ]); |
| 108 | 108 | |
| 109 | 109 | expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]); |
| ... | ... | @@ -119,10 +119,10 @@ describe("uiStateStore pure functions", () => { |
| 119 | 119 | }, |
| 120 | 120 | projectOrder: [project1], |
| 121 | 121 | }), |
| 122 | [{ id: project1, cwd: "/tmp/project-1" }], | |
| 122 | [{ key: project1, cwd: "/tmp/project-1" }], | |
| 123 | 123 | ); |
| 124 | 124 | |
| 125 | const next = syncProjects(initialState, [{ id: project1, cwd: "/tmp/project-1-renamed" }]); | |
| 125 | const next = syncProjects(initialState, [{ key: project1, cwd: "/tmp/project-1-renamed" }]); | |
| 126 | 126 | |
| 127 | 127 | expect(next).not.toBe(initialState); |
| 128 | 128 | expect(next.projectOrder).toEqual([project1]); |
| ... | ... | @@ -139,7 +139,7 @@ describe("uiStateStore pure functions", () => { |
| 139 | 139 | }, |
| 140 | 140 | }); |
| 141 | 141 | |
| 142 | const next = syncThreads(initialState, [{ id: thread1 }]); | |
| 142 | const next = syncThreads(initialState, [{ key: thread1 }]); | |
| 143 | 143 | |
| 144 | 144 | expect(next.threadLastVisitedAtById).toEqual({ |
| 145 | 145 | [thread1]: "2026-02-25T12:35:00.000Z", |
| ... | ... | @@ -152,7 +152,7 @@ describe("uiStateStore pure functions", () => { |
| 152 | 152 | |
| 153 | 153 | const next = syncThreads(initialState, [ |
| 154 | 154 | { |
| 155 | id: thread1, | |
| 155 | key: thread1, | |
| 156 | 156 | seedVisitedAt: "2026-02-25T12:35:00.000Z", |
| 157 | 157 | }, |
| 158 | 158 | ]); |
apps/web/src/uiStateStore.ts+30-35| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | 1 | import { Debouncer } from "@tanstack/react-pacer"; |
| 2 | import { type ProjectId, type ThreadId } from "@t3tools/contracts"; | |
| 3 | 2 | import { create } from "zustand"; |
| 4 | 3 | |
| 5 | 4 | const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; |
| ... | ... | @@ -23,7 +22,7 @@ interface PersistedUiState { |
| 23 | 22 | |
| 24 | 23 | export interface UiProjectState { |
| 25 | 24 | projectExpandedById: Record<string, boolean>; |
| 26 | projectOrder: ProjectId[]; | |
| 25 | projectOrder: string[]; | |
| 27 | 26 | } |
| 28 | 27 | |
| 29 | 28 | export interface UiThreadState { |
| ... | ... | @@ -33,12 +32,12 @@ export interface UiThreadState { |
| 33 | 32 | export interface UiState extends UiProjectState, UiThreadState {} |
| 34 | 33 | |
| 35 | 34 | export interface SyncProjectInput { |
| 36 | id: ProjectId; | |
| 35 | key: string; | |
| 37 | 36 | cwd: string; |
| 38 | 37 | } |
| 39 | 38 | |
| 40 | 39 | export interface SyncThreadInput { |
| 41 | id: ThreadId; | |
| 40 | key: string; | |
| 42 | 41 | seedVisitedAt?: string | undefined; |
| 43 | 42 | } |
| 44 | 43 | |
| ... | ... | @@ -50,7 +49,7 @@ const initialState: UiState = { |
| 50 | 49 | |
| 51 | 50 | const persistedExpandedProjectCwds = new Set<string>(); |
| 52 | 51 | const persistedProjectOrderCwds: string[] = []; |
| 53 | const currentProjectCwdById = new Map<ProjectId, string>(); | |
| 52 | const currentProjectCwdById = new Map<string, string>(); | |
| 54 | 53 | let legacyKeysCleanedUp = false; |
| 55 | 54 | |
| 56 | 55 | function readPersistedState(): UiState { |
| ... | ... | @@ -100,7 +99,7 @@ function persistState(state: UiState): void { |
| 100 | 99 | const expandedProjectCwds = Object.entries(state.projectExpandedById) |
| 101 | 100 | .filter(([, expanded]) => expanded) |
| 102 | 101 | .flatMap(([projectId]) => { |
| 103 | const cwd = currentProjectCwdById.get(projectId as ProjectId); | |
| 102 | const cwd = currentProjectCwdById.get(projectId); | |
| 104 | 103 | return cwd ? [cwd] : []; |
| 105 | 104 | }); |
| 106 | 105 | const projectOrderCwds = state.projectOrder.flatMap((projectId) => { |
| ... | ... | @@ -141,7 +140,7 @@ function recordsEqual<T>(left: Record<string, T>, right: Record<string, T>): boo |
| 141 | 140 | return true; |
| 142 | 141 | } |
| 143 | 142 | |
| 144 | function projectOrdersEqual(left: readonly ProjectId[], right: readonly ProjectId[]): boolean { | |
| 143 | function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean { | |
| 145 | 144 | return ( |
| 146 | 145 | left.length === right.length && left.every((projectId, index) => projectId === right[index]) |
| 147 | 146 | ); |
| ... | ... | @@ -154,11 +153,11 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput |
| 154 | 153 | ); |
| 155 | 154 | currentProjectCwdById.clear(); |
| 156 | 155 | for (const project of projects) { |
| 157 | currentProjectCwdById.set(project.id, project.cwd); | |
| 156 | currentProjectCwdById.set(project.key, project.cwd); | |
| 158 | 157 | } |
| 159 | 158 | const cwdMappingChanged = |
| 160 | 159 | previousProjectCwdById.size !== currentProjectCwdById.size || |
| 161 | projects.some((project) => previousProjectCwdById.get(project.id) !== project.cwd); | |
| 160 | projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd); | |
| 162 | 161 | |
| 163 | 162 | const nextExpandedById: Record<string, boolean> = {}; |
| 164 | 163 | const previousExpandedById = state.projectExpandedById; |
| ... | ... | @@ -168,14 +167,14 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput |
| 168 | 167 | const mappedProjects = projects.map((project, index) => { |
| 169 | 168 | const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd); |
| 170 | 169 | const expanded = |
| 171 | previousExpandedById[project.id] ?? | |
| 170 | previousExpandedById[project.key] ?? | |
| 172 | 171 | (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ?? |
| 173 | 172 | (persistedExpandedProjectCwds.size > 0 |
| 174 | 173 | ? persistedExpandedProjectCwds.has(project.cwd) |
| 175 | 174 | : true); |
| 176 | nextExpandedById[project.id] = expanded; | |
| 175 | nextExpandedById[project.key] = expanded; | |
| 177 | 176 | return { |
| 178 | id: project.id, | |
| 177 | id: project.key, | |
| 179 | 178 | cwd: project.cwd, |
| 180 | 179 | incomingIndex: index, |
| 181 | 180 | }; |
| ... | ... | @@ -187,8 +186,8 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput |
| 187 | 186 | const nextProjectIdByCwd = new Map( |
| 188 | 187 | mappedProjects.map((project) => [project.cwd, project.id] as const), |
| 189 | 188 | ); |
| 190 | const usedProjectIds = new Set<ProjectId>(); | |
| 191 | const orderedProjectIds: ProjectId[] = []; | |
| 189 | const usedProjectIds = new Set<string>(); | |
| 190 | const orderedProjectIds: string[] = []; | |
| 192 | 191 | |
| 193 | 192 | for (const projectId of state.projectOrder) { |
| 194 | 193 | const matchedProjectId = |
| ... | ... | @@ -246,19 +245,19 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput |
| 246 | 245 | } |
| 247 | 246 | |
| 248 | 247 | export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState { |
| 249 | const retainedThreadIds = new Set(threads.map((thread) => thread.id)); | |
| 248 | const retainedThreadIds = new Set(threads.map((thread) => thread.key)); | |
| 250 | 249 | const nextThreadLastVisitedAtById = Object.fromEntries( |
| 251 | 250 | Object.entries(state.threadLastVisitedAtById).filter(([threadId]) => |
| 252 | retainedThreadIds.has(threadId as ThreadId), | |
| 251 | retainedThreadIds.has(threadId), | |
| 253 | 252 | ), |
| 254 | 253 | ); |
| 255 | 254 | for (const thread of threads) { |
| 256 | 255 | if ( |
| 257 | nextThreadLastVisitedAtById[thread.id] === undefined && | |
| 256 | nextThreadLastVisitedAtById[thread.key] === undefined && | |
| 258 | 257 | thread.seedVisitedAt !== undefined && |
| 259 | 258 | thread.seedVisitedAt.length > 0 |
| 260 | 259 | ) { |
| 261 | nextThreadLastVisitedAtById[thread.id] = thread.seedVisitedAt; | |
| 260 | nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt; | |
| 262 | 261 | } |
| 263 | 262 | } |
| 264 | 263 | if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) { |
| ... | ... | @@ -270,7 +269,7 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) |
| 270 | 269 | }; |
| 271 | 270 | } |
| 272 | 271 | |
| 273 | export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt?: string): UiState { | |
| 272 | export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState { | |
| 274 | 273 | const at = visitedAt ?? new Date().toISOString(); |
| 275 | 274 | const visitedAtMs = Date.parse(at); |
| 276 | 275 | const previousVisitedAt = state.threadLastVisitedAtById[threadId]; |
| ... | ... | @@ -293,7 +292,7 @@ export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt? |
| 293 | 292 | |
| 294 | 293 | export function markThreadUnread( |
| 295 | 294 | state: UiState, |
| 296 | threadId: ThreadId, | |
| 295 | threadId: string, | |
| 297 | 296 | latestTurnCompletedAt: string | null | undefined, |
| 298 | 297 | ): UiState { |
| 299 | 298 | if (!latestTurnCompletedAt) { |
| ... | ... | @@ -316,7 +315,7 @@ export function markThreadUnread( |
| 316 | 315 | }; |
| 317 | 316 | } |
| 318 | 317 | |
| 319 | export function clearThreadUi(state: UiState, threadId: ThreadId): UiState { | |
| 318 | export function clearThreadUi(state: UiState, threadId: string): UiState { | |
| 320 | 319 | if (!(threadId in state.threadLastVisitedAtById)) { |
| 321 | 320 | return state; |
| 322 | 321 | } |
| ... | ... | @@ -328,7 +327,7 @@ export function clearThreadUi(state: UiState, threadId: ThreadId): UiState { |
| 328 | 327 | }; |
| 329 | 328 | } |
| 330 | 329 | |
| 331 | export function toggleProject(state: UiState, projectId: ProjectId): UiState { | |
| 330 | export function toggleProject(state: UiState, projectId: string): UiState { | |
| 332 | 331 | const expanded = state.projectExpandedById[projectId] ?? true; |
| 333 | 332 | return { |
| 334 | 333 | ...state, |
| ... | ... | @@ -339,11 +338,7 @@ export function toggleProject(state: UiState, projectId: ProjectId): UiState { |
| 339 | 338 | }; |
| 340 | 339 | } |
| 341 | 340 | |
| 342 | export function setProjectExpanded( | |
| 343 | state: UiState, | |
| 344 | projectId: ProjectId, | |
| 345 | expanded: boolean, | |
| 346 | ): UiState { | |
| 341 | export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState { | |
| 347 | 342 | if ((state.projectExpandedById[projectId] ?? true) === expanded) { |
| 348 | 343 | return state; |
| 349 | 344 | } |
| ... | ... | @@ -358,8 +353,8 @@ export function setProjectExpanded( |
| 358 | 353 | |
| 359 | 354 | export function reorderProjects( |
| 360 | 355 | state: UiState, |
| 361 | draggedProjectId: ProjectId, | |
| 362 | targetProjectId: ProjectId, | |
| 356 | draggedProjectId: string, | |
| 357 | targetProjectId: string, | |
| 363 | 358 | ): UiState { |
| 364 | 359 | if (draggedProjectId === targetProjectId) { |
| 365 | 360 | return state; |
| ... | ... | @@ -384,12 +379,12 @@ export function reorderProjects( |
| 384 | 379 | interface UiStateStore extends UiState { |
| 385 | 380 | syncProjects: (projects: readonly SyncProjectInput[]) => void; |
| 386 | 381 | syncThreads: (threads: readonly SyncThreadInput[]) => void; |
| 387 | markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; | |
| 388 | markThreadUnread: (threadId: ThreadId, latestTurnCompletedAt: string | null | undefined) => void; | |
| 389 | clearThreadUi: (threadId: ThreadId) => void; | |
| 390 | toggleProject: (projectId: ProjectId) => void; | |
| 391 | setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void; | |
| 392 | reorderProjects: (draggedProjectId: ProjectId, targetProjectId: ProjectId) => void; | |
| 382 | markThreadVisited: (threadId: string, visitedAt?: string) => void; | |
| 383 | markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; | |
| 384 | clearThreadUi: (threadId: string) => void; | |
| 385 | toggleProject: (projectId: string) => void; | |
| 386 | setProjectExpanded: (projectId: string, expanded: boolean) => void; | |
| 387 | reorderProjects: (draggedProjectId: string, targetProjectId: string) => void; | |
| 393 | 388 | } |
| 394 | 389 | |
| 395 | 390 | export const useUiStateStore = create<UiStateStore>((set) => ({ |
apps/web/src/vite-env.d.ts+2-2| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | /// <reference types="vite/client" /> |
| 2 | 2 | |
| 3 | import type { NativeApi, DesktopBridge } from "@t3tools/contracts"; | |
| 3 | import type { DesktopBridge, LocalApi } from "@t3tools/contracts"; | |
| 4 | 4 | |
| 5 | 5 | interface ImportMetaEnv { |
| 6 | 6 | readonly APP_VERSION: string; |
| ... | ... | @@ -12,7 +12,7 @@ interface ImportMeta { |
| 12 | 12 | |
| 13 | 13 | declare global { |
| 14 | 14 | interface Window { |
| 15 | nativeApi?: NativeApi; | |
| 15 | nativeApi?: LocalApi; | |
| 16 | 16 | desktopBridge?: DesktopBridge; |
| 17 | 17 | } |
| 18 | 18 | } |
apps/web/src/worktreeCleanup.test.ts+4-1| ... | ... | @@ -1,12 +1,15 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; |
| 5 | 5 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; |
| 6 | 6 | |
| 7 | const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); | |
| 8 | ||
| 7 | 9 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 8 | 10 | return { |
| 9 | 11 | id: ThreadId.makeUnsafe("thread-1"), |
| 12 | environmentId: localEnvironmentId, | |
| 10 | 13 | codexThreadId: null, |
| 11 | 14 | projectId: ProjectId.makeUnsafe("project-1"), |
| 12 | 15 | title: "Thread", |
apps/web/src/wsNativeApi.test.ts deleted-417| ... | ... | @@ -1,417 +0,0 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | DEFAULT_SERVER_SETTINGS, | |
| 4 | type DesktopBridge, | |
| 5 | EventId, | |
| 6 | type GitStatusResult, | |
| 7 | ProjectId, | |
| 8 | type OrchestrationEvent, | |
| 9 | type ServerConfig, | |
| 10 | type ServerProvider, | |
| 11 | type TerminalEvent, | |
| 12 | ThreadId, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 15 | ||
| 16 | import type { ContextMenuItem } from "@t3tools/contracts"; | |
| 17 | ||
| 18 | const showContextMenuFallbackMock = | |
| 19 | vi.fn< | |
| 20 | <T extends string>( | |
| 21 | items: readonly ContextMenuItem<T>[], | |
| 22 | position?: { x: number; y: number }, | |
| 23 | ) => Promise<T | null> | |
| 24 | >(); | |
| 25 | ||
| 26 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | |
| 27 | listeners.add(listener); | |
| 28 | return () => { | |
| 29 | listeners.delete(listener); | |
| 30 | }; | |
| 31 | } | |
| 32 | ||
| 33 | const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); | |
| 34 | const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); | |
| 35 | const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); | |
| 36 | ||
| 37 | const rpcClientMock = { | |
| 38 | dispose: vi.fn(), | |
| 39 | terminal: { | |
| 40 | open: vi.fn(), | |
| 41 | write: vi.fn(), | |
| 42 | resize: vi.fn(), | |
| 43 | clear: vi.fn(), | |
| 44 | restart: vi.fn(), | |
| 45 | close: vi.fn(), | |
| 46 | onEvent: vi.fn((listener: (event: TerminalEvent) => void) => | |
| 47 | registerListener(terminalEventListeners, listener), | |
| 48 | ), | |
| 49 | }, | |
| 50 | projects: { | |
| 51 | searchEntries: vi.fn(), | |
| 52 | writeFile: vi.fn(), | |
| 53 | }, | |
| 54 | shell: { | |
| 55 | openInEditor: vi.fn(), | |
| 56 | }, | |
| 57 | git: { | |
| 58 | pull: vi.fn(), | |
| 59 | refreshStatus: vi.fn(), | |
| 60 | onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => | |
| 61 | registerListener(gitStatusListeners, listener), | |
| 62 | ), | |
| 63 | runStackedAction: vi.fn(), | |
| 64 | listBranches: vi.fn(), | |
| 65 | createWorktree: vi.fn(), | |
| 66 | removeWorktree: vi.fn(), | |
| 67 | createBranch: vi.fn(), | |
| 68 | checkout: vi.fn(), | |
| 69 | init: vi.fn(), | |
| 70 | resolvePullRequest: vi.fn(), | |
| 71 | preparePullRequestThread: vi.fn(), | |
| 72 | }, | |
| 73 | server: { | |
| 74 | getConfig: vi.fn(), | |
| 75 | refreshProviders: vi.fn(), | |
| 76 | upsertKeybinding: vi.fn(), | |
| 77 | getSettings: vi.fn(), | |
| 78 | updateSettings: vi.fn(), | |
| 79 | subscribeConfig: vi.fn(), | |
| 80 | subscribeLifecycle: vi.fn(), | |
| 81 | }, | |
| 82 | orchestration: { | |
| 83 | getSnapshot: vi.fn(), | |
| 84 | dispatchCommand: vi.fn(), | |
| 85 | getTurnDiff: vi.fn(), | |
| 86 | getFullThreadDiff: vi.fn(), | |
| 87 | replayEvents: vi.fn(), | |
| 88 | onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) => | |
| 89 | registerListener(orchestrationEventListeners, listener), | |
| 90 | ), | |
| 91 | }, | |
| 92 | }; | |
| 93 | ||
| 94 | vi.mock("./wsRpcClient", () => { | |
| 95 | return { | |
| 96 | getWsRpcClient: () => rpcClientMock, | |
| 97 | __resetWsRpcClientForTests: vi.fn(), | |
| 98 | }; | |
| 99 | }); | |
| 100 | ||
| 101 | vi.mock("./contextMenuFallback", () => ({ | |
| 102 | showContextMenuFallback: showContextMenuFallbackMock, | |
| 103 | })); | |
| 104 | ||
| 105 | function emitEvent<T>(listeners: Set<(event: T) => void>, event: T) { | |
| 106 | for (const listener of listeners) { | |
| 107 | listener(event); | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } { | |
| 112 | const testGlobal = globalThis as typeof globalThis & { | |
| 113 | window?: Window & typeof globalThis & { desktopBridge?: unknown }; | |
| 114 | }; | |
| 115 | if (!testGlobal.window) { | |
| 116 | testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown }; | |
| 117 | } | |
| 118 | return testGlobal.window; | |
| 119 | } | |
| 120 | ||
| 121 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { | |
| 122 | return { | |
| 123 | getWsUrl: () => null, | |
| 124 | pickFolder: async () => null, | |
| 125 | confirm: async () => true, | |
| 126 | setTheme: async () => undefined, | |
| 127 | showContextMenu: async () => null, | |
| 128 | openExternal: async () => true, | |
| 129 | onMenuAction: () => () => undefined, | |
| 130 | getUpdateState: async () => { | |
| 131 | throw new Error("getUpdateState not implemented in test"); | |
| 132 | }, | |
| 133 | checkForUpdate: async () => { | |
| 134 | throw new Error("checkForUpdate not implemented in test"); | |
| 135 | }, | |
| 136 | downloadUpdate: async () => { | |
| 137 | throw new Error("downloadUpdate not implemented in test"); | |
| 138 | }, | |
| 139 | installUpdate: async () => { | |
| 140 | throw new Error("installUpdate not implemented in test"); | |
| 141 | }, | |
| 142 | onUpdateState: () => () => undefined, | |
| 143 | ...overrides, | |
| 144 | }; | |
| 145 | } | |
| 146 | ||
| 147 | const defaultProviders: ReadonlyArray<ServerProvider> = [ | |
| 148 | { | |
| 149 | provider: "codex", | |
| 150 | enabled: true, | |
| 151 | installed: true, | |
| 152 | version: "0.116.0", | |
| 153 | status: "ready", | |
| 154 | auth: { status: "authenticated" }, | |
| 155 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 156 | models: [], | |
| 157 | }, | |
| 158 | ]; | |
| 159 | ||
| 160 | const baseServerConfig: ServerConfig = { | |
| 161 | cwd: "/tmp/workspace", | |
| 162 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", | |
| 163 | keybindings: [], | |
| 164 | issues: [], | |
| 165 | providers: defaultProviders, | |
| 166 | availableEditors: ["cursor"], | |
| 167 | observability: { | |
| 168 | logsDirectoryPath: "/tmp/workspace/.config/logs", | |
| 169 | localTracingEnabled: true, | |
| 170 | otlpTracesEnabled: false, | |
| 171 | otlpMetricsEnabled: false, | |
| 172 | }, | |
| 173 | settings: DEFAULT_SERVER_SETTINGS, | |
| 174 | }; | |
| 175 | ||
| 176 | const baseGitStatus: GitStatusResult = { | |
| 177 | isRepo: true, | |
| 178 | hasOriginRemote: true, | |
| 179 | isDefaultBranch: false, | |
| 180 | branch: "feature/streamed", | |
| 181 | hasWorkingTreeChanges: false, | |
| 182 | workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 183 | hasUpstream: true, | |
| 184 | aheadCount: 0, | |
| 185 | behindCount: 0, | |
| 186 | pr: null, | |
| 187 | }; | |
| 188 | ||
| 189 | beforeEach(() => { | |
| 190 | vi.resetModules(); | |
| 191 | vi.clearAllMocks(); | |
| 192 | showContextMenuFallbackMock.mockReset(); | |
| 193 | terminalEventListeners.clear(); | |
| 194 | orchestrationEventListeners.clear(); | |
| 195 | gitStatusListeners.clear(); | |
| 196 | Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); | |
| 197 | }); | |
| 198 | ||
| 199 | afterEach(() => { | |
| 200 | vi.restoreAllMocks(); | |
| 201 | }); | |
| 202 | ||
| 203 | describe("wsNativeApi", () => { | |
| 204 | it("forwards server config fetches directly to the RPC client", async () => { | |
| 205 | rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); | |
| 206 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 207 | ||
| 208 | const api = createWsNativeApi(); | |
| 209 | ||
| 210 | await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); | |
| 211 | expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); | |
| 212 | expect(rpcClientMock.server.subscribeConfig).not.toHaveBeenCalled(); | |
| 213 | expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); | |
| 214 | }); | |
| 215 | ||
| 216 | it("forwards terminal and orchestration stream events", async () => { | |
| 217 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 218 | ||
| 219 | const api = createWsNativeApi(); | |
| 220 | const onTerminalEvent = vi.fn(); | |
| 221 | const onDomainEvent = vi.fn(); | |
| 222 | ||
| 223 | api.terminal.onEvent(onTerminalEvent); | |
| 224 | api.orchestration.onDomainEvent(onDomainEvent); | |
| 225 | ||
| 226 | const terminalEvent = { | |
| 227 | threadId: "thread-1", | |
| 228 | terminalId: "terminal-1", | |
| 229 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 230 | type: "output", | |
| 231 | data: "hello", | |
| 232 | } as const; | |
| 233 | emitEvent(terminalEventListeners, terminalEvent); | |
| 234 | ||
| 235 | const orchestrationEvent = { | |
| 236 | sequence: 1, | |
| 237 | eventId: EventId.makeUnsafe("event-1"), | |
| 238 | aggregateKind: "project", | |
| 239 | aggregateId: ProjectId.makeUnsafe("project-1"), | |
| 240 | occurredAt: "2026-02-24T00:00:00.000Z", | |
| 241 | commandId: null, | |
| 242 | causationEventId: null, | |
| 243 | correlationId: null, | |
| 244 | metadata: {}, | |
| 245 | type: "project.created", | |
| 246 | payload: { | |
| 247 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 248 | title: "Project", | |
| 249 | workspaceRoot: "/tmp/workspace", | |
| 250 | defaultModelSelection: { | |
| 251 | provider: "codex", | |
| 252 | model: "gpt-5-codex", | |
| 253 | }, | |
| 254 | scripts: [], | |
| 255 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 256 | updatedAt: "2026-02-24T00:00:00.000Z", | |
| 257 | }, | |
| 258 | } satisfies Extract<OrchestrationEvent, { type: "project.created" }>; | |
| 259 | emitEvent(orchestrationEventListeners, orchestrationEvent); | |
| 260 | ||
| 261 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); | |
| 262 | expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); | |
| 263 | }); | |
| 264 | ||
| 265 | it("forwards git status stream events", async () => { | |
| 266 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 267 | ||
| 268 | const api = createWsNativeApi(); | |
| 269 | const onStatus = vi.fn(); | |
| 270 | ||
| 271 | api.git.onStatus({ cwd: "/repo" }, onStatus); | |
| 272 | ||
| 273 | const gitStatus = baseGitStatus; | |
| 274 | emitEvent(gitStatusListeners, gitStatus); | |
| 275 | ||
| 276 | expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); | |
| 277 | expect(onStatus).toHaveBeenCalledWith(gitStatus); | |
| 278 | }); | |
| 279 | ||
| 280 | it("forwards git status refreshes directly to the RPC client", async () => { | |
| 281 | rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); | |
| 282 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 283 | ||
| 284 | const api = createWsNativeApi(); | |
| 285 | ||
| 286 | await api.git.refreshStatus({ cwd: "/repo" }); | |
| 287 | ||
| 288 | expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); | |
| 289 | }); | |
| 290 | ||
| 291 | it("forwards orchestration stream subscription options to the RPC client", async () => { | |
| 292 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 293 | ||
| 294 | const api = createWsNativeApi(); | |
| 295 | const onDomainEvent = vi.fn(); | |
| 296 | const onResubscribe = vi.fn(); | |
| 297 | ||
| 298 | api.orchestration.onDomainEvent(onDomainEvent, { onResubscribe }); | |
| 299 | ||
| 300 | expect(rpcClientMock.orchestration.onDomainEvent).toHaveBeenCalledWith(onDomainEvent, { | |
| 301 | onResubscribe, | |
| 302 | }); | |
| 303 | }); | |
| 304 | ||
| 305 | it("sends orchestration dispatch commands as the direct RPC payload", async () => { | |
| 306 | rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); | |
| 307 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 308 | ||
| 309 | const api = createWsNativeApi(); | |
| 310 | const command = { | |
| 311 | type: "project.create", | |
| 312 | commandId: CommandId.makeUnsafe("cmd-1"), | |
| 313 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 314 | title: "Project", | |
| 315 | workspaceRoot: "/tmp/project", | |
| 316 | defaultModelSelection: { | |
| 317 | provider: "codex", | |
| 318 | model: "gpt-5-codex", | |
| 319 | }, | |
| 320 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 321 | } as const; | |
| 322 | await api.orchestration.dispatchCommand(command); | |
| 323 | ||
| 324 | expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command); | |
| 325 | }); | |
| 326 | ||
| 327 | it("forwards workspace file writes to the project RPC", async () => { | |
| 328 | rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); | |
| 329 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 330 | ||
| 331 | const api = createWsNativeApi(); | |
| 332 | await api.projects.writeFile({ | |
| 333 | cwd: "/tmp/project", | |
| 334 | relativePath: "plan.md", | |
| 335 | contents: "# Plan\n", | |
| 336 | }); | |
| 337 | ||
| 338 | expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({ | |
| 339 | cwd: "/tmp/project", | |
| 340 | relativePath: "plan.md", | |
| 341 | contents: "# Plan\n", | |
| 342 | }); | |
| 343 | }); | |
| 344 | ||
| 345 | it("forwards full-thread diff requests to the orchestration RPC", async () => { | |
| 346 | rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); | |
| 347 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 348 | ||
| 349 | const api = createWsNativeApi(); | |
| 350 | await api.orchestration.getFullThreadDiff({ | |
| 351 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 352 | toTurnCount: 1, | |
| 353 | }); | |
| 354 | ||
| 355 | expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({ | |
| 356 | threadId: "thread-1", | |
| 357 | toTurnCount: 1, | |
| 358 | }); | |
| 359 | }); | |
| 360 | ||
| 361 | it("forwards provider refreshes directly to the RPC client", async () => { | |
| 362 | const nextProviders: ReadonlyArray<ServerProvider> = [ | |
| 363 | { | |
| 364 | ...defaultProviders[0]!, | |
| 365 | checkedAt: "2026-01-03T00:00:00.000Z", | |
| 366 | }, | |
| 367 | ]; | |
| 368 | rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); | |
| 369 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 370 | ||
| 371 | const api = createWsNativeApi(); | |
| 372 | ||
| 373 | await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); | |
| 374 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); | |
| 375 | }); | |
| 376 | ||
| 377 | it("forwards server settings updates directly to the RPC client", async () => { | |
| 378 | const nextSettings = { | |
| 379 | ...DEFAULT_SERVER_SETTINGS, | |
| 380 | enableAssistantStreaming: true, | |
| 381 | }; | |
| 382 | rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); | |
| 383 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 384 | ||
| 385 | const api = createWsNativeApi(); | |
| 386 | ||
| 387 | await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( | |
| 388 | nextSettings, | |
| 389 | ); | |
| 390 | expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({ | |
| 391 | enableAssistantStreaming: true, | |
| 392 | }); | |
| 393 | }); | |
| 394 | ||
| 395 | it("forwards context menu metadata to the desktop bridge", async () => { | |
| 396 | const showContextMenu = vi.fn().mockResolvedValue("delete"); | |
| 397 | getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); | |
| 398 | ||
| 399 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 400 | const api = createWsNativeApi(); | |
| 401 | const items = [{ id: "delete", label: "Delete" }] as const; | |
| 402 | ||
| 403 | await expect(api.contextMenu.show(items)).resolves.toBe("delete"); | |
| 404 | expect(showContextMenu).toHaveBeenCalledWith(items, undefined); | |
| 405 | }); | |
| 406 | ||
| 407 | it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { | |
| 408 | showContextMenuFallbackMock.mockResolvedValue("rename"); | |
| 409 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 410 | ||
| 411 | const api = createWsNativeApi(); | |
| 412 | const items = [{ id: "rename", label: "Rename" }] as const; | |
| 413 | ||
| 414 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); | |
| 415 | expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); | |
| 416 | }); | |
| 417 | }); |
apps/web/src/wsNativeApi.ts deleted-117| ... | ... | @@ -1,117 +0,0 @@ |
| 1 | import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import { resetGitStatusStateForTests } from "./lib/gitStatusState"; | |
| 4 | import { showContextMenuFallback } from "./contextMenuFallback"; | |
| 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 { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient"; | |
| 10 | ||
| 11 | let instance: { api: NativeApi } | null = null; | |
| 12 | ||
| 13 | export async function __resetWsNativeApiForTests() { | |
| 14 | instance = null; | |
| 15 | await __resetWsRpcAtomClientForTests(); | |
| 16 | await __resetWsRpcClientForTests(); | |
| 17 | resetGitStatusStateForTests(); | |
| 18 | resetRequestLatencyStateForTests(); | |
| 19 | resetServerStateForTests(); | |
| 20 | resetWsConnectionStateForTests(); | |
| 21 | } | |
| 22 | ||
| 23 | export function createWsNativeApi(): NativeApi { | |
| 24 | if (instance) { | |
| 25 | return instance.api; | |
| 26 | } | |
| 27 | ||
| 28 | const rpcClient = getWsRpcClient(); | |
| 29 | ||
| 30 | const api: NativeApi = { | |
| 31 | dialogs: { | |
| 32 | pickFolder: async () => { | |
| 33 | if (!window.desktopBridge) return null; | |
| 34 | return window.desktopBridge.pickFolder(); | |
| 35 | }, | |
| 36 | confirm: async (message) => { | |
| 37 | if (window.desktopBridge) { | |
| 38 | return window.desktopBridge.confirm(message); | |
| 39 | } | |
| 40 | return window.confirm(message); | |
| 41 | }, | |
| 42 | }, | |
| 43 | terminal: { | |
| 44 | open: (input) => rpcClient.terminal.open(input as never), | |
| 45 | write: (input) => rpcClient.terminal.write(input as never), | |
| 46 | resize: (input) => rpcClient.terminal.resize(input as never), | |
| 47 | clear: (input) => rpcClient.terminal.clear(input as never), | |
| 48 | restart: (input) => rpcClient.terminal.restart(input as never), | |
| 49 | close: (input) => rpcClient.terminal.close(input as never), | |
| 50 | onEvent: (callback) => rpcClient.terminal.onEvent(callback), | |
| 51 | }, | |
| 52 | projects: { | |
| 53 | searchEntries: rpcClient.projects.searchEntries, | |
| 54 | writeFile: rpcClient.projects.writeFile, | |
| 55 | }, | |
| 56 | shell: { | |
| 57 | openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), | |
| 58 | openExternal: async (url) => { | |
| 59 | if (window.desktopBridge) { | |
| 60 | const opened = await window.desktopBridge.openExternal(url); | |
| 61 | if (!opened) { | |
| 62 | throw new Error("Unable to open link."); | |
| 63 | } | |
| 64 | return; | |
| 65 | } | |
| 66 | ||
| 67 | window.open(url, "_blank", "noopener,noreferrer"); | |
| 68 | }, | |
| 69 | }, | |
| 70 | git: { | |
| 71 | pull: rpcClient.git.pull, | |
| 72 | refreshStatus: rpcClient.git.refreshStatus, | |
| 73 | onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), | |
| 74 | listBranches: rpcClient.git.listBranches, | |
| 75 | createWorktree: rpcClient.git.createWorktree, | |
| 76 | removeWorktree: rpcClient.git.removeWorktree, | |
| 77 | createBranch: rpcClient.git.createBranch, | |
| 78 | checkout: rpcClient.git.checkout, | |
| 79 | init: rpcClient.git.init, | |
| 80 | resolvePullRequest: rpcClient.git.resolvePullRequest, | |
| 81 | preparePullRequestThread: rpcClient.git.preparePullRequestThread, | |
| 82 | }, | |
| 83 | contextMenu: { | |
| 84 | show: async <T extends string>( | |
| 85 | items: readonly ContextMenuItem<T>[], | |
| 86 | position?: { x: number; y: number }, | |
| 87 | ): Promise<T | null> => { | |
| 88 | if (window.desktopBridge) { | |
| 89 | return window.desktopBridge.showContextMenu(items, position) as Promise<T | null>; | |
| 90 | } | |
| 91 | return showContextMenuFallback(items, position); | |
| 92 | }, | |
| 93 | }, | |
| 94 | server: { | |
| 95 | getConfig: rpcClient.server.getConfig, | |
| 96 | refreshProviders: rpcClient.server.refreshProviders, | |
| 97 | upsertKeybinding: rpcClient.server.upsertKeybinding, | |
| 98 | getSettings: rpcClient.server.getSettings, | |
| 99 | updateSettings: rpcClient.server.updateSettings, | |
| 100 | }, | |
| 101 | orchestration: { | |
| 102 | getSnapshot: rpcClient.orchestration.getSnapshot, | |
| 103 | dispatchCommand: rpcClient.orchestration.dispatchCommand, | |
| 104 | getTurnDiff: rpcClient.orchestration.getTurnDiff, | |
| 105 | getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, | |
| 106 | replayEvents: (fromSequenceExclusive) => | |
| 107 | rpcClient.orchestration | |
| 108 | .replayEvents({ fromSequenceExclusive }) | |
| 109 | .then((events) => [...events]), | |
| 110 | onDomainEvent: (callback, options) => | |
| 111 | rpcClient.orchestration.onDomainEvent(callback, options), | |
| 112 | }, | |
| 113 | }; | |
| 114 | ||
| 115 | instance = { api }; | |
| 116 | return api; | |
| 117 | } |
apps/web/src/wsRpcClient.test.ts+38-2| ... | ... | @@ -3,9 +3,25 @@ import type { |
| 3 | 3 | GitStatusRemoteResult, |
| 4 | 4 | GitStatusStreamEvent, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | import { describe, expect, it, vi } from "vitest"; | |
| 6 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 7 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 7 | 8 | |
| 8 | import { createWsRpcClient } from "./wsRpcClient"; | |
| 9 | vi.mock("./wsTransport", () => ({ | |
| 10 | WsTransport: class WsTransport { | |
| 11 | dispose = vi.fn(async () => undefined); | |
| 12 | reconnect = vi.fn(async () => undefined); | |
| 13 | request = vi.fn(); | |
| 14 | requestStream = vi.fn(); | |
| 15 | subscribe = vi.fn(() => () => undefined); | |
| 16 | }, | |
| 17 | })); | |
| 18 | ||
| 19 | import { | |
| 20 | __resetWsRpcClientForTests, | |
| 21 | createWsRpcClient, | |
| 22 | ensureWsRpcClientEntryForKnownEnvironment, | |
| 23 | readWsRpcClientEntryForEnvironment, | |
| 24 | } from "./wsRpcClient"; | |
| 9 | 25 | import { type WsTransport } from "./wsTransport"; |
| 10 | 26 | |
| 11 | 27 | const baseLocalStatus: GitStatusLocalResult = { |
| ... | ... | @@ -25,6 +41,10 @@ const baseRemoteStatus: GitStatusRemoteResult = { |
| 25 | 41 | }; |
| 26 | 42 | |
| 27 | 43 | describe("wsRpcClient", () => { |
| 44 | afterEach(async () => { | |
| 45 | await __resetWsRpcClientForTests(); | |
| 46 | }); | |
| 47 | ||
| 28 | 48 | it("reduces git status stream events into flat status snapshots", () => { |
| 29 | 49 | const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => { |
| 30 | 50 | for (const event of [ |
| ... | ... | @@ -91,4 +111,20 @@ describe("wsRpcClient", () => { |
| 91 | 111 | ], |
| 92 | 112 | ]); |
| 93 | 113 | }); |
| 114 | ||
| 115 | it("does not fall back to the only registered client for an unbound environment", () => { | |
| 116 | ensureWsRpcClientEntryForKnownEnvironment({ | |
| 117 | id: "known-env-a", | |
| 118 | label: "Environment A", | |
| 119 | source: "manual", | |
| 120 | target: { | |
| 121 | type: "ws", | |
| 122 | wsUrl: "ws://localhost:3000", | |
| 123 | }, | |
| 124 | }); | |
| 125 | ||
| 126 | expect( | |
| 127 | readWsRpcClientEntryForEnvironment(EnvironmentId.makeUnsafe("environment-b")), | |
| 128 | ).toBeNull(); | |
| 129 | }); | |
| 94 | 130 | }); |
apps/web/src/wsRpcClient.ts+153-12| ... | ... | @@ -4,14 +4,20 @@ import { |
| 4 | 4 | type GitRunStackedActionResult, |
| 5 | 5 | type GitStatusResult, |
| 6 | 6 | type GitStatusStreamEvent, |
| 7 | type NativeApi, | |
| 7 | type LocalApi, | |
| 8 | 8 | ORCHESTRATION_WS_METHODS, |
| 9 | type EnvironmentId, | |
| 9 | 10 | type ServerSettingsPatch, |
| 10 | 11 | WS_METHODS, |
| 11 | 12 | } from "@t3tools/contracts"; |
| 13 | import { getKnownEnvironmentBaseUrl, type KnownEnvironment } from "@t3tools/client-runtime"; | |
| 12 | 14 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; |
| 13 | 15 | import { Effect, Stream } from "effect"; |
| 14 | 16 | |
| 17 | import { | |
| 18 | getPrimaryKnownEnvironment, | |
| 19 | resolvePrimaryEnvironmentBootstrapUrl, | |
| 20 | } from "./environmentBootstrap"; | |
| 15 | 21 | import { type WsRpcProtocolClient } from "./rpc/protocol"; |
| 16 | 22 | import { resetWsReconnectBackoff } from "./rpc/wsConnectionState"; |
| 17 | 23 | import { WsTransport } from "./wsTransport"; |
| ... | ... | @@ -61,9 +67,9 @@ export interface WsRpcClient { |
| 61 | 67 | }; |
| 62 | 68 | readonly shell: { |
| 63 | 69 | readonly openInEditor: (input: { |
| 64 | readonly cwd: Parameters<NativeApi["shell"]["openInEditor"]>[0]; | |
| 65 | readonly editor: Parameters<NativeApi["shell"]["openInEditor"]>[1]; | |
| 66 | }) => ReturnType<NativeApi["shell"]["openInEditor"]>; | |
| 70 | readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0]; | |
| 71 | readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1]; | |
| 72 | }) => ReturnType<LocalApi["shell"]["openInEditor"]>; | |
| 67 | 73 | }; |
| 68 | 74 | readonly git: { |
| 69 | 75 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; |
| ... | ... | @@ -109,22 +115,157 @@ export interface WsRpcClient { |
| 109 | 115 | }; |
| 110 | 116 | } |
| 111 | 117 | |
| 112 | let sharedWsRpcClient: WsRpcClient | null = null; | |
| 118 | export interface WsRpcClientEntry { | |
| 119 | readonly key: string; | |
| 120 | readonly knownEnvironment: KnownEnvironment; | |
| 121 | readonly client: WsRpcClient; | |
| 122 | readonly environmentId: EnvironmentId | null; | |
| 123 | } | |
| 124 | ||
| 125 | type MutableWsRpcClientEntry = { | |
| 126 | key: string; | |
| 127 | knownEnvironment: KnownEnvironment; | |
| 128 | client: WsRpcClient; | |
| 129 | environmentId: EnvironmentId | null; | |
| 130 | }; | |
| 131 | ||
| 132 | const wsRpcClientEntriesByKey = new Map<string, MutableWsRpcClientEntry>(); | |
| 133 | const wsRpcClientKeyByEnvironmentId = new Map<EnvironmentId, string>(); | |
| 134 | const wsRpcClientRegistryListeners = new Set<() => void>(); | |
| 135 | ||
| 136 | function emitWsRpcClientRegistryChange() { | |
| 137 | for (const listener of wsRpcClientRegistryListeners) { | |
| 138 | listener(); | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | function toReadonlyEntry(entry: MutableWsRpcClientEntry): WsRpcClientEntry { | |
| 143 | return entry; | |
| 144 | } | |
| 145 | ||
| 146 | function createWsRpcClientEntry(knownEnvironment: KnownEnvironment): MutableWsRpcClientEntry { | |
| 147 | const baseUrl = getKnownEnvironmentBaseUrl(knownEnvironment); | |
| 148 | if (!baseUrl) { | |
| 149 | throw new Error(`Unable to resolve websocket bootstrap URL for ${knownEnvironment.label}.`); | |
| 150 | } | |
| 151 | ||
| 152 | return { | |
| 153 | key: knownEnvironment.id, | |
| 154 | knownEnvironment, | |
| 155 | client: createWsRpcClient(new WsTransport(baseUrl)), | |
| 156 | environmentId: knownEnvironment.environmentId ?? null, | |
| 157 | }; | |
| 158 | } | |
| 159 | ||
| 160 | export function subscribeWsRpcClientRegistry(listener: () => void): () => void { | |
| 161 | wsRpcClientRegistryListeners.add(listener); | |
| 162 | return () => { | |
| 163 | wsRpcClientRegistryListeners.delete(listener); | |
| 164 | }; | |
| 165 | } | |
| 166 | ||
| 167 | export function listWsRpcClientEntries(): ReadonlyArray<WsRpcClientEntry> { | |
| 168 | return [...wsRpcClientEntriesByKey.values()].map(toReadonlyEntry); | |
| 169 | } | |
| 170 | ||
| 171 | export function ensureWsRpcClientEntryForKnownEnvironment( | |
| 172 | knownEnvironment: KnownEnvironment, | |
| 173 | ): WsRpcClientEntry { | |
| 174 | const existingEntry = wsRpcClientEntriesByKey.get(knownEnvironment.id); | |
| 175 | if (existingEntry) { | |
| 176 | return toReadonlyEntry(existingEntry); | |
| 177 | } | |
| 178 | ||
| 179 | const entry = createWsRpcClientEntry(knownEnvironment); | |
| 180 | wsRpcClientEntriesByKey.set(entry.key, entry); | |
| 181 | if (entry.environmentId) { | |
| 182 | wsRpcClientKeyByEnvironmentId.set(entry.environmentId, entry.key); | |
| 183 | } | |
| 184 | emitWsRpcClientRegistryChange(); | |
| 185 | return toReadonlyEntry(entry); | |
| 186 | } | |
| 187 | ||
| 188 | export function getPrimaryWsRpcClientEntry(): WsRpcClientEntry { | |
| 189 | const primaryKnownEnvironment = getPrimaryKnownEnvironment(); | |
| 190 | if (!primaryKnownEnvironment) { | |
| 191 | throw new Error("Unable to resolve the primary websocket environment."); | |
| 192 | } | |
| 193 | ||
| 194 | return ensureWsRpcClientEntryForKnownEnvironment(primaryKnownEnvironment); | |
| 195 | } | |
| 113 | 196 | |
| 114 | 197 | export function getWsRpcClient(): WsRpcClient { |
| 115 | if (sharedWsRpcClient) { | |
| 116 | return sharedWsRpcClient; | |
| 198 | return getPrimaryWsRpcClientEntry().client; | |
| 199 | } | |
| 200 | ||
| 201 | export function bindWsRpcClientEntryEnvironment( | |
| 202 | clientKey: string, | |
| 203 | environmentId: EnvironmentId, | |
| 204 | ): void { | |
| 205 | const entry = wsRpcClientEntriesByKey.get(clientKey); | |
| 206 | if (!entry) { | |
| 207 | throw new Error(`No websocket client registered for key ${clientKey}.`); | |
| 208 | } | |
| 209 | ||
| 210 | const previousBoundEnvironmentId = entry.environmentId; | |
| 211 | const previousKeyForEnvironment = wsRpcClientKeyByEnvironmentId.get(environmentId); | |
| 212 | ||
| 213 | if (previousBoundEnvironmentId === environmentId && previousKeyForEnvironment === clientKey) { | |
| 214 | return; | |
| 215 | } | |
| 216 | ||
| 217 | if (previousBoundEnvironmentId) { | |
| 218 | wsRpcClientKeyByEnvironmentId.delete(previousBoundEnvironmentId); | |
| 219 | } | |
| 220 | ||
| 221 | if (previousKeyForEnvironment && previousKeyForEnvironment !== clientKey) { | |
| 222 | const previousEntry = wsRpcClientEntriesByKey.get(previousKeyForEnvironment); | |
| 223 | if (previousEntry) { | |
| 224 | previousEntry.environmentId = null; | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | entry.environmentId = environmentId; | |
| 229 | wsRpcClientKeyByEnvironmentId.set(environmentId, clientKey); | |
| 230 | emitWsRpcClientRegistryChange(); | |
| 231 | } | |
| 232 | ||
| 233 | export function bindPrimaryWsRpcClientEnvironment(environmentId: EnvironmentId): void { | |
| 234 | bindWsRpcClientEntryEnvironment(getPrimaryWsRpcClientEntry().key, environmentId); | |
| 235 | } | |
| 236 | ||
| 237 | export function readWsRpcClientEntryForEnvironment( | |
| 238 | environmentId: EnvironmentId, | |
| 239 | ): WsRpcClientEntry | null { | |
| 240 | const clientKey = wsRpcClientKeyByEnvironmentId.get(environmentId); | |
| 241 | if (clientKey) { | |
| 242 | const entry = wsRpcClientEntriesByKey.get(clientKey); | |
| 243 | return entry ? toReadonlyEntry(entry) : null; | |
| 244 | } | |
| 245 | ||
| 246 | return null; | |
| 247 | } | |
| 248 | ||
| 249 | export function getWsRpcClientForEnvironment(environmentId: EnvironmentId): WsRpcClient { | |
| 250 | const entry = readWsRpcClientEntryForEnvironment(environmentId); | |
| 251 | if (!entry) { | |
| 252 | throw new Error(`No websocket client registered for environment ${environmentId}.`); | |
| 117 | 253 | } |
| 118 | sharedWsRpcClient = createWsRpcClient(); | |
| 119 | return sharedWsRpcClient; | |
| 254 | return entry.client; | |
| 120 | 255 | } |
| 121 | 256 | |
| 122 | 257 | export async function __resetWsRpcClientForTests() { |
| 123 | await sharedWsRpcClient?.dispose(); | |
| 124 | sharedWsRpcClient = null; | |
| 258 | for (const entry of wsRpcClientEntriesByKey.values()) { | |
| 259 | await entry.client.dispose(); | |
| 260 | } | |
| 261 | wsRpcClientEntriesByKey.clear(); | |
| 262 | wsRpcClientKeyByEnvironmentId.clear(); | |
| 263 | wsRpcClientRegistryListeners.clear(); | |
| 125 | 264 | } |
| 126 | 265 | |
| 127 | export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { | |
| 266 | export function createWsRpcClient( | |
| 267 | transport = new WsTransport(resolvePrimaryEnvironmentBootstrapUrl()), | |
| 268 | ): WsRpcClient { | |
| 128 | 269 | return { |
| 129 | 270 | dispose: () => transport.dispose(), |
| 130 | 271 | reconnect: async () => { |
apps/web/src/wsTransport.test.ts+21| ... | ... | @@ -436,6 +436,13 @@ describe("WsTransport", () => { |
| 436 | 436 | sequence: 1, |
| 437 | 437 | type: "welcome", |
| 438 | 438 | payload: { |
| 439 | environment: { | |
| 440 | environmentId: "environment-local", | |
| 441 | label: "Local environment", | |
| 442 | platform: { os: "darwin", arch: "arm64" }, | |
| 443 | serverVersion: "0.0.0-test", | |
| 444 | capabilities: { repositoryIdentity: true }, | |
| 445 | }, | |
| 439 | 446 | cwd: "/tmp/workspace", |
| 440 | 447 | projectName: "workspace", |
| 441 | 448 | }, |
| ... | ... | @@ -489,6 +496,13 @@ describe("WsTransport", () => { |
| 489 | 496 | sequence: 1, |
| 490 | 497 | type: "welcome", |
| 491 | 498 | payload: { |
| 499 | environment: { | |
| 500 | environmentId: "environment-local", | |
| 501 | label: "Local environment", | |
| 502 | platform: { os: "darwin", arch: "arm64" }, | |
| 503 | serverVersion: "0.0.0-test", | |
| 504 | capabilities: { repositoryIdentity: true }, | |
| 505 | }, | |
| 492 | 506 | cwd: "/tmp/one", |
| 493 | 507 | projectName: "one", |
| 494 | 508 | }, |
| ... | ... | @@ -532,6 +546,13 @@ describe("WsTransport", () => { |
| 532 | 546 | sequence: 2, |
| 533 | 547 | type: "welcome", |
| 534 | 548 | payload: { |
| 549 | environment: { | |
| 550 | environmentId: "environment-local", | |
| 551 | label: "Local environment", | |
| 552 | platform: { os: "darwin", arch: "arm64" }, | |
| 553 | serverVersion: "0.0.0-test", | |
| 554 | capabilities: { repositoryIdentity: true }, | |
| 555 | }, | |
| 535 | 556 | cwd: "/tmp/two", |
| 536 | 557 | projectName: "two", |
| 537 | 558 | }, |
bun.lock+15| ... | ... | @@ -82,6 +82,7 @@ |
| 82 | 82 | "@formkit/auto-animate": "^0.9.0", |
| 83 | 83 | "@lexical/react": "^0.41.0", |
| 84 | 84 | "@pierre/diffs": "^1.1.0-beta.16", |
| 85 | "@t3tools/client-runtime": "workspace:*", | |
| 85 | 86 | "@t3tools/contracts": "workspace:*", |
| 86 | 87 | "@t3tools/shared": "workspace:*", |
| 87 | 88 | "@tanstack/react-pacer": "^0.19.4", |
| ... | ... | @@ -121,6 +122,18 @@ |
| 121 | 122 | "vitest-browser-react": "^2.0.5", |
| 122 | 123 | }, |
| 123 | 124 | }, |
| 125 | "packages/client-runtime": { | |
| 126 | "name": "@t3tools/client-runtime", | |
| 127 | "version": "0.0.0-alpha.1", | |
| 128 | "dependencies": { | |
| 129 | "@t3tools/contracts": "workspace:*", | |
| 130 | }, | |
| 131 | "devDependencies": { | |
| 132 | "@effect/language-service": "catalog:", | |
| 133 | "typescript": "catalog:", | |
| 134 | "vitest": "catalog:", | |
| 135 | }, | |
| 136 | }, | |
| 124 | 137 | "packages/contracts": { |
| 125 | 138 | "name": "@t3tools/contracts", |
| 126 | 139 | "version": "0.0.15", |
| ... | ... | @@ -659,6 +672,8 @@ |
| 659 | 672 | |
| 660 | 673 | "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], |
| 661 | 674 | |
| 675 | "@t3tools/client-runtime": ["@t3tools/client-runtime@workspace:packages/client-runtime"], | |
| 676 | ||
| 662 | 677 | "@t3tools/contracts": ["@t3tools/contracts@workspace:packages/contracts"], |
| 663 | 678 | |
| 664 | 679 | "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"], |
packages/client-runtime/package.json created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | { | |
| 2 | "name": "@t3tools/client-runtime", | |
| 3 | "version": "0.0.0-alpha.1", | |
| 4 | "private": true, | |
| 5 | "type": "module", | |
| 6 | "exports": { | |
| 7 | ".": { | |
| 8 | "types": "./src/index.ts", | |
| 9 | "import": "./src/index.ts" | |
| 10 | } | |
| 11 | }, | |
| 12 | "scripts": { | |
| 13 | "prepare": "effect-language-service patch", | |
| 14 | "typecheck": "tsc --noEmit", | |
| 15 | "test": "vitest run" | |
| 16 | }, | |
| 17 | "dependencies": { | |
| 18 | "@t3tools/contracts": "workspace:*" | |
| 19 | }, | |
| 20 | "devDependencies": { | |
| 21 | "@effect/language-service": "catalog:", | |
| 22 | "typescript": "catalog:", | |
| 23 | "vitest": "catalog:" | |
| 24 | } | |
| 25 | } |
packages/client-runtime/src/index.ts created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | export * from "./knownEnvironment"; | |
| 2 | export * from "./scoped"; |
packages/client-runtime/src/knownEnvironment.test.ts created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { createKnownEnvironmentFromWsUrl } from "./knownEnvironment"; | |
| 5 | import { | |
| 6 | parseScopedProjectKey, | |
| 7 | parseScopedThreadKey, | |
| 8 | scopedProjectKey, | |
| 9 | scopedRefKey, | |
| 10 | scopedThreadKey, | |
| 11 | scopeProjectRef, | |
| 12 | scopeThreadRef, | |
| 13 | } from "./scoped"; | |
| 14 | ||
| 15 | describe("known environment bootstrap helpers", () => { | |
| 16 | it("creates known environments from explicit ws urls", () => { | |
| 17 | expect( | |
| 18 | createKnownEnvironmentFromWsUrl({ | |
| 19 | label: "Remote environment", | |
| 20 | wsUrl: "wss://remote.example.com/ws", | |
| 21 | }), | |
| 22 | ).toEqual({ | |
| 23 | id: "ws:Remote environment", | |
| 24 | label: "Remote environment", | |
| 25 | source: "manual", | |
| 26 | target: { | |
| 27 | type: "ws", | |
| 28 | wsUrl: "wss://remote.example.com/ws", | |
| 29 | }, | |
| 30 | }); | |
| 31 | }); | |
| 32 | }); | |
| 33 | ||
| 34 | describe("scoped refs", () => { | |
| 35 | const environmentId = EnvironmentId.makeUnsafe("environment-test"); | |
| 36 | const projectRef = scopeProjectRef(environmentId, ProjectId.makeUnsafe("project-1")); | |
| 37 | const threadRef = scopeThreadRef(environmentId, ThreadId.makeUnsafe("thread-1")); | |
| 38 | ||
| 39 | it("builds stable scoped project and thread keys", () => { | |
| 40 | expect(scopedRefKey(projectRef)).toBe("environment-test:project-1"); | |
| 41 | expect(scopedRefKey(threadRef)).toBe("environment-test:thread-1"); | |
| 42 | expect(scopedProjectKey(projectRef)).toBe("environment-test:project-1"); | |
| 43 | expect(scopedThreadKey(threadRef)).toBe("environment-test:thread-1"); | |
| 44 | }); | |
| 45 | ||
| 46 | it("returns typed scoped refs", () => { | |
| 47 | expect(projectRef).toEqual({ | |
| 48 | environmentId, | |
| 49 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 50 | }); | |
| 51 | expect(threadRef).toEqual({ | |
| 52 | environmentId, | |
| 53 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | it("parses scoped project and thread keys back into refs", () => { | |
| 58 | expect(parseScopedProjectKey("environment-test:project-1")).toEqual(projectRef); | |
| 59 | expect(parseScopedThreadKey("environment-test:thread-1")).toEqual(threadRef); | |
| 60 | expect(parseScopedProjectKey("bad-key")).toBeNull(); | |
| 61 | expect(parseScopedThreadKey("bad-key")).toBeNull(); | |
| 62 | }); | |
| 63 | }); |
packages/client-runtime/src/knownEnvironment.ts created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export interface KnownEnvironmentConnectionTarget { | |
| 4 | readonly type: "ws"; | |
| 5 | readonly wsUrl: string; | |
| 6 | } | |
| 7 | ||
| 8 | export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin"; | |
| 9 | ||
| 10 | export interface KnownEnvironment { | |
| 11 | readonly id: string; | |
| 12 | readonly label: string; | |
| 13 | readonly source: KnownEnvironmentSource; | |
| 14 | readonly environmentId?: EnvironmentId; | |
| 15 | readonly target: KnownEnvironmentConnectionTarget; | |
| 16 | } | |
| 17 | ||
| 18 | export function createKnownEnvironmentFromWsUrl(input: { | |
| 19 | readonly id?: string; | |
| 20 | readonly label: string; | |
| 21 | readonly source?: KnownEnvironmentSource; | |
| 22 | readonly wsUrl: string; | |
| 23 | }): KnownEnvironment { | |
| 24 | return { | |
| 25 | id: input.id ?? `ws:${input.label}`, | |
| 26 | label: input.label, | |
| 27 | source: input.source ?? "manual", | |
| 28 | target: { | |
| 29 | type: "ws", | |
| 30 | wsUrl: input.wsUrl, | |
| 31 | }, | |
| 32 | }; | |
| 33 | } | |
| 34 | ||
| 35 | export function getKnownEnvironmentBaseUrl( | |
| 36 | environment: KnownEnvironment | null | undefined, | |
| 37 | ): string | null { | |
| 38 | return environment?.target.wsUrl ?? null; | |
| 39 | } |
packages/client-runtime/src/scoped.ts created+64| ... | ... | @@ -0,0 +1,64 @@ |
| 1 | import type { | |
| 2 | EnvironmentId, | |
| 3 | ProjectId, | |
| 4 | ScopedProjectRef, | |
| 5 | ScopedThreadRef, | |
| 6 | ThreadId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | ||
| 9 | export function scopeProjectRef( | |
| 10 | environmentId: EnvironmentId, | |
| 11 | projectId: ProjectId, | |
| 12 | ): ScopedProjectRef { | |
| 13 | return { environmentId, projectId }; | |
| 14 | } | |
| 15 | ||
| 16 | export function scopeThreadRef(environmentId: EnvironmentId, threadId: ThreadId): ScopedThreadRef { | |
| 17 | return { environmentId, threadId }; | |
| 18 | } | |
| 19 | ||
| 20 | export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { | |
| 21 | const localId = "projectId" in ref ? ref.projectId : ref.threadId; | |
| 22 | return `${ref.environmentId}:${localId}`; | |
| 23 | } | |
| 24 | ||
| 25 | export function scopedProjectKey(ref: ScopedProjectRef): string { | |
| 26 | return scopedRefKey(ref); | |
| 27 | } | |
| 28 | ||
| 29 | export function scopedThreadKey(ref: ScopedThreadRef): string { | |
| 30 | return scopedRefKey(ref); | |
| 31 | } | |
| 32 | ||
| 33 | function parseScopedKey(key: string): { environmentId: EnvironmentId; localId: string } | null { | |
| 34 | const separatorIndex = key.indexOf(":"); | |
| 35 | if (separatorIndex <= 0 || separatorIndex >= key.length - 1) { | |
| 36 | return null; | |
| 37 | } | |
| 38 | return { | |
| 39 | environmentId: key.slice(0, separatorIndex) as EnvironmentId, | |
| 40 | localId: key.slice(separatorIndex + 1), | |
| 41 | }; | |
| 42 | } | |
| 43 | ||
| 44 | export function parseScopedProjectKey(key: string): ScopedProjectRef | null { | |
| 45 | const parsed = parseScopedKey(key); | |
| 46 | if (!parsed) { | |
| 47 | return null; | |
| 48 | } | |
| 49 | return { | |
| 50 | environmentId: parsed.environmentId, | |
| 51 | projectId: parsed.localId as ProjectId, | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | export function parseScopedThreadKey(key: string): ScopedThreadRef | null { | |
| 56 | const parsed = parseScopedKey(key); | |
| 57 | if (!parsed) { | |
| 58 | return null; | |
| 59 | } | |
| 60 | return { | |
| 61 | environmentId: parsed.environmentId, | |
| 62 | threadId: parsed.localId as ThreadId, | |
| 63 | }; | |
| 64 | } |
packages/client-runtime/tsconfig.json created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | { | |
| 2 | "extends": "../../tsconfig.base.json", | |
| 3 | "include": ["src"] | |
| 4 | } |
packages/contracts/src/baseSchemas.ts+2| ... | ... | @@ -19,6 +19,8 @@ export const ThreadId = makeEntityId("ThreadId"); |
| 19 | 19 | export type ThreadId = typeof ThreadId.Type; |
| 20 | 20 | export const ProjectId = makeEntityId("ProjectId"); |
| 21 | 21 | export type ProjectId = typeof ProjectId.Type; |
| 22 | export const EnvironmentId = makeEntityId("EnvironmentId"); | |
| 23 | export type EnvironmentId = typeof EnvironmentId.Type; | |
| 22 | 24 | export const CommandId = makeEntityId("CommandId"); |
| 23 | 25 | export type CommandId = typeof CommandId.Type; |
| 24 | 26 | export const EventId = makeEntityId("EventId"); |
packages/contracts/src/environment.ts created+77| ... | ... | @@ -0,0 +1,77 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | ||
| 3 | import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; | |
| 4 | ||
| 5 | export const ExecutionEnvironmentPlatformOs = Schema.Literals([ | |
| 6 | "darwin", | |
| 7 | "linux", | |
| 8 | "windows", | |
| 9 | "unknown", | |
| 10 | ]); | |
| 11 | export type ExecutionEnvironmentPlatformOs = typeof ExecutionEnvironmentPlatformOs.Type; | |
| 12 | ||
| 13 | export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]); | |
| 14 | export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; | |
| 15 | ||
| 16 | export const ExecutionEnvironmentPlatform = Schema.Struct({ | |
| 17 | os: ExecutionEnvironmentPlatformOs, | |
| 18 | arch: ExecutionEnvironmentPlatformArch, | |
| 19 | }); | |
| 20 | export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; | |
| 21 | ||
| 22 | export const ExecutionEnvironmentCapabilities = Schema.Struct({ | |
| 23 | repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), | |
| 24 | }); | |
| 25 | export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; | |
| 26 | ||
| 27 | export const ExecutionEnvironmentDescriptor = Schema.Struct({ | |
| 28 | environmentId: EnvironmentId, | |
| 29 | label: TrimmedNonEmptyString, | |
| 30 | platform: ExecutionEnvironmentPlatform, | |
| 31 | serverVersion: TrimmedNonEmptyString, | |
| 32 | capabilities: ExecutionEnvironmentCapabilities, | |
| 33 | }); | |
| 34 | export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; | |
| 35 | ||
| 36 | export const EnvironmentConnectionState = Schema.Literals([ | |
| 37 | "connecting", | |
| 38 | "connected", | |
| 39 | "disconnected", | |
| 40 | "error", | |
| 41 | ]); | |
| 42 | export type EnvironmentConnectionState = typeof EnvironmentConnectionState.Type; | |
| 43 | ||
| 44 | export const RepositoryIdentityLocator = Schema.Struct({ | |
| 45 | source: Schema.Literal("git-remote"), | |
| 46 | remoteName: TrimmedNonEmptyString, | |
| 47 | remoteUrl: TrimmedNonEmptyString, | |
| 48 | }); | |
| 49 | export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; | |
| 50 | ||
| 51 | export const RepositoryIdentity = Schema.Struct({ | |
| 52 | canonicalKey: TrimmedNonEmptyString, | |
| 53 | locator: RepositoryIdentityLocator, | |
| 54 | displayName: Schema.optionalKey(TrimmedNonEmptyString), | |
| 55 | provider: Schema.optionalKey(TrimmedNonEmptyString), | |
| 56 | owner: Schema.optionalKey(TrimmedNonEmptyString), | |
| 57 | name: Schema.optionalKey(TrimmedNonEmptyString), | |
| 58 | }); | |
| 59 | export type RepositoryIdentity = typeof RepositoryIdentity.Type; | |
| 60 | ||
| 61 | export const ScopedProjectRef = Schema.Struct({ | |
| 62 | environmentId: EnvironmentId, | |
| 63 | projectId: ProjectId, | |
| 64 | }); | |
| 65 | export type ScopedProjectRef = typeof ScopedProjectRef.Type; | |
| 66 | ||
| 67 | export const ScopedThreadRef = Schema.Struct({ | |
| 68 | environmentId: EnvironmentId, | |
| 69 | threadId: ThreadId, | |
| 70 | }); | |
| 71 | export type ScopedThreadRef = typeof ScopedThreadRef.Type; | |
| 72 | ||
| 73 | export const ScopedThreadSessionRef = Schema.Struct({ | |
| 74 | environmentId: EnvironmentId, | |
| 75 | threadId: ThreadId, | |
| 76 | }); | |
| 77 | export type ScopedThreadSessionRef = typeof ScopedThreadSessionRef.Type; |
packages/contracts/src/index.ts+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | export * from "./baseSchemas"; |
| 2 | export * from "./environment"; | |
| 2 | 3 | export * from "./ipc"; |
| 3 | 4 | export * from "./terminal"; |
| 4 | 5 | export * from "./provider"; |
packages/contracts/src/ipc.ts+46-20| ... | ... | @@ -105,8 +105,14 @@ export interface DesktopUpdateCheckResult { |
| 105 | 105 | state: DesktopUpdateState; |
| 106 | 106 | } |
| 107 | 107 | |
| 108 | export interface DesktopEnvironmentBootstrap { | |
| 109 | label: string; | |
| 110 | wsUrl: string | null; | |
| 111 | } | |
| 112 | ||
| 108 | 113 | export interface DesktopBridge { |
| 109 | 114 | getWsUrl: () => string | null; |
| 115 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; | |
| 110 | 116 | pickFolder: () => Promise<string | null>; |
| 111 | 117 | confirm: (message: string) => Promise<boolean>; |
| 112 | 118 | setTheme: (theme: DesktopTheme) => Promise<void>; |
| ... | ... | @@ -123,11 +129,50 @@ export interface DesktopBridge { |
| 123 | 129 | onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; |
| 124 | 130 | } |
| 125 | 131 | |
| 126 | export interface NativeApi { | |
| 132 | /** | |
| 133 | * APIs bound to the local app shell, not to any particular backend environment. | |
| 134 | * | |
| 135 | * These capabilities describe the desktop/browser host that the user is | |
| 136 | * currently running: dialogs, editor/external-link opening, context menus, and | |
| 137 | * app-level settings/config access. They must not be used as a proxy for | |
| 138 | * "whatever environment the user is targeting", because in a multi-environment | |
| 139 | * world the local shell and a selected backend environment are distinct | |
| 140 | * concepts. | |
| 141 | */ | |
| 142 | export interface LocalApi { | |
| 127 | 143 | dialogs: { |
| 128 | 144 | pickFolder: () => Promise<string | null>; |
| 129 | 145 | confirm: (message: string) => Promise<boolean>; |
| 130 | 146 | }; |
| 147 | shell: { | |
| 148 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; | |
| 149 | openExternal: (url: string) => Promise<void>; | |
| 150 | }; | |
| 151 | contextMenu: { | |
| 152 | show: <T extends string>( | |
| 153 | items: readonly ContextMenuItem<T>[], | |
| 154 | position?: { x: number; y: number }, | |
| 155 | ) => Promise<T | null>; | |
| 156 | }; | |
| 157 | server: { | |
| 158 | getConfig: () => Promise<ServerConfig>; | |
| 159 | refreshProviders: () => Promise<ServerProviderUpdatedPayload>; | |
| 160 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | |
| 161 | getSettings: () => Promise<ServerSettings>; | |
| 162 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; | |
| 163 | }; | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * APIs bound to a specific backend environment connection. | |
| 168 | * | |
| 169 | * These operations must always be routed with explicit environment context. | |
| 170 | * They represent remote stateful capabilities such as orchestration, terminal, | |
| 171 | * project, and git operations. In multi-environment mode, each environment gets | |
| 172 | * its own instance of this surface, and callers should resolve it by | |
| 173 | * `environmentId` rather than reaching through the local desktop bridge. | |
| 174 | */ | |
| 175 | export interface EnvironmentApi { | |
| 131 | 176 | terminal: { |
| 132 | 177 | open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>; |
| 133 | 178 | write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>; |
| ... | ... | @@ -141,12 +186,7 @@ export interface NativeApi { |
| 141 | 186 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; |
| 142 | 187 | writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>; |
| 143 | 188 | }; |
| 144 | shell: { | |
| 145 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; | |
| 146 | openExternal: (url: string) => Promise<void>; | |
| 147 | }; | |
| 148 | 189 | git: { |
| 149 | // Existing branch/worktree API | |
| 150 | 190 | listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>; |
| 151 | 191 | createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>; |
| 152 | 192 | removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>; |
| ... | ... | @@ -157,7 +197,6 @@ export interface NativeApi { |
| 157 | 197 | preparePullRequestThread: ( |
| 158 | 198 | input: GitPreparePullRequestThreadInput, |
| 159 | 199 | ) => Promise<GitPreparePullRequestThreadResult>; |
| 160 | // Stacked action API | |
| 161 | 200 | pull: (input: GitPullInput) => Promise<GitPullResult>; |
| 162 | 201 | refreshStatus: (input: GitStatusInput) => Promise<GitStatusResult>; |
| 163 | 202 | onStatus: ( |
| ... | ... | @@ -168,19 +207,6 @@ export interface NativeApi { |
| 168 | 207 | }, |
| 169 | 208 | ) => () => void; |
| 170 | 209 | }; |
| 171 | contextMenu: { | |
| 172 | show: <T extends string>( | |
| 173 | items: readonly ContextMenuItem<T>[], | |
| 174 | position?: { x: number; y: number }, | |
| 175 | ) => Promise<T | null>; | |
| 176 | }; | |
| 177 | server: { | |
| 178 | getConfig: () => Promise<ServerConfig>; | |
| 179 | refreshProviders: () => Promise<ServerProviderUpdatedPayload>; | |
| 180 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | |
| 181 | getSettings: () => Promise<ServerSettings>; | |
| 182 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; | |
| 183 | }; | |
| 184 | 210 | orchestration: { |
| 185 | 211 | getSnapshot: () => Promise<OrchestrationReadModel>; |
| 186 | 212 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; |
packages/contracts/src/orchestration.ts+4| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { Option, Schema, SchemaIssue, Struct } from "effect"; |
| 2 | 2 | import { ClaudeModelOptions, CodexModelOptions } from "./model"; |
| 3 | import { RepositoryIdentity } from "./environment"; | |
| 3 | 4 | import { |
| 4 | 5 | ApprovalRequestId, |
| 5 | 6 | CheckpointRef, |
| ... | ... | @@ -141,6 +142,7 @@ export const OrchestrationProject = Schema.Struct({ |
| 141 | 142 | id: ProjectId, |
| 142 | 143 | title: TrimmedNonEmptyString, |
| 143 | 144 | workspaceRoot: TrimmedNonEmptyString, |
| 145 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | |
| 144 | 146 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 145 | 147 | scripts: Schema.Array(ProjectScript), |
| 146 | 148 | createdAt: IsoDateTime, |
| ... | ... | @@ -647,6 +649,7 @@ export const ProjectCreatedPayload = Schema.Struct({ |
| 647 | 649 | projectId: ProjectId, |
| 648 | 650 | title: TrimmedNonEmptyString, |
| 649 | 651 | workspaceRoot: TrimmedNonEmptyString, |
| 652 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | |
| 650 | 653 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 651 | 654 | scripts: Schema.Array(ProjectScript), |
| 652 | 655 | createdAt: IsoDateTime, |
| ... | ... | @@ -657,6 +660,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ |
| 657 | 660 | projectId: ProjectId, |
| 658 | 661 | title: Schema.optional(TrimmedNonEmptyString), |
| 659 | 662 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), |
| 663 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | |
| 660 | 664 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 661 | 665 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 662 | 666 | updatedAt: IsoDateTime, |
packages/contracts/src/server.ts+4| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { Schema } from "effect"; |
| 2 | import { ExecutionEnvironmentDescriptor } from "./environment"; | |
| 2 | 3 | import { |
| 3 | 4 | IsoDateTime, |
| 4 | 5 | NonNegativeInt, |
| ... | ... | @@ -83,6 +84,7 @@ export const ServerObservability = Schema.Struct({ |
| 83 | 84 | export type ServerObservability = typeof ServerObservability.Type; |
| 84 | 85 | |
| 85 | 86 | export const ServerConfig = Schema.Struct({ |
| 87 | environment: ExecutionEnvironmentDescriptor, | |
| 86 | 88 | cwd: TrimmedNonEmptyString, |
| 87 | 89 | keybindingsConfigPath: TrimmedNonEmptyString, |
| 88 | 90 | keybindings: ResolvedKeybindingsConfig, |
| ... | ... | @@ -167,10 +169,12 @@ export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; |
| 167 | 169 | |
| 168 | 170 | export const ServerLifecycleReadyPayload = Schema.Struct({ |
| 169 | 171 | at: IsoDateTime, |
| 172 | environment: ExecutionEnvironmentDescriptor, | |
| 170 | 173 | }); |
| 171 | 174 | export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type; |
| 172 | 175 | |
| 173 | 176 | export const ServerLifecycleWelcomePayload = Schema.Struct({ |
| 177 | environment: ExecutionEnvironmentDescriptor, | |
| 174 | 178 | cwd: TrimmedNonEmptyString, |
| 175 | 179 | projectName: TrimmedNonEmptyString, |
| 176 | 180 | bootstrapProjectId: Schema.optional(ProjectId), |
packages/shared/src/git.test.ts+48-1| ... | ... | @@ -1,7 +1,54 @@ |
| 1 | 1 | import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | import { applyGitStatusStreamEvent } from "./git"; | |
| 4 | import { | |
| 5 | applyGitStatusStreamEvent, | |
| 6 | normalizeGitRemoteUrl, | |
| 7 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl, | |
| 8 | } from "./git"; | |
| 9 | ||
| 10 | describe("normalizeGitRemoteUrl", () => { | |
| 11 | it("canonicalizes equivalent GitHub remotes across protocol variants", () => { | |
| 12 | expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( | |
| 13 | "github.com/t3tools/t3code", | |
| 14 | ); | |
| 15 | expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe( | |
| 16 | "github.com/t3tools/t3code", | |
| 17 | ); | |
| 18 | expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe( | |
| 19 | "github.com/t3tools/t3code", | |
| 20 | ); | |
| 21 | }); | |
| 22 | ||
| 23 | it("preserves nested group paths for providers like GitLab", () => { | |
| 24 | expect(normalizeGitRemoteUrl("git@gitlab.com:T3Tools/platform/T3Code.git")).toBe( | |
| 25 | "gitlab.com/t3tools/platform/t3code", | |
| 26 | ); | |
| 27 | expect(normalizeGitRemoteUrl("https://gitlab.com/T3Tools/platform/T3Code.git")).toBe( | |
| 28 | "gitlab.com/t3tools/platform/t3code", | |
| 29 | ); | |
| 30 | }); | |
| 31 | ||
| 32 | it("drops explicit ports from URL-shaped remotes", () => { | |
| 33 | expect(normalizeGitRemoteUrl("https://gitlab.company.com:8443/team/project.git")).toBe( | |
| 34 | "gitlab.company.com/team/project", | |
| 35 | ); | |
| 36 | expect(normalizeGitRemoteUrl("ssh://git@gitlab.company.com:2222/team/project.git")).toBe( | |
| 37 | "gitlab.company.com/team/project", | |
| 38 | ); | |
| 39 | }); | |
| 40 | }); | |
| 41 | ||
| 42 | describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { | |
| 43 | it("extracts the owner and repository from common GitHub remote shapes", () => { | |
| 44 | expect( | |
| 45 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), | |
| 46 | ).toBe("T3Tools/T3Code"); | |
| 47 | expect( | |
| 48 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), | |
| 49 | ).toBe("T3Tools/T3Code"); | |
| 50 | }); | |
| 51 | }); | |
| 5 | 52 | |
| 6 | 53 | describe("applyGitStatusStreamEvent", () => { |
| 7 | 54 | it("treats a remote-only update as a repository when local state is missing", () => { |
packages/shared/src/git.ts+50| ... | ... | @@ -80,6 +80,56 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { |
| 80 | 80 | return branchName.slice(firstSeparatorIndex + 1); |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | /** | |
| 84 | * Normalize a git remote URL into a stable comparison key. | |
| 85 | */ | |
| 86 | export function normalizeGitRemoteUrl(value: string): string { | |
| 87 | const normalized = value | |
| 88 | .trim() | |
| 89 | .replace(/\/+$/g, "") | |
| 90 | .replace(/\.git$/i, "") | |
| 91 | .toLowerCase(); | |
| 92 | ||
| 93 | if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { | |
| 94 | try { | |
| 95 | const url = new URL(normalized); | |
| 96 | const repositoryPath = url.pathname | |
| 97 | .split("/") | |
| 98 | .filter((segment) => segment.length > 0) | |
| 99 | .join("/"); | |
| 100 | if (url.hostname && repositoryPath.includes("/")) { | |
| 101 | return `${url.hostname}/${repositoryPath}`; | |
| 102 | } | |
| 103 | } catch { | |
| 104 | return normalized; | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); | |
| 109 | if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { | |
| 110 | return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; | |
| 111 | } | |
| 112 | ||
| 113 | return normalized; | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes. | |
| 118 | */ | |
| 119 | export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { | |
| 120 | const trimmed = url?.trim() ?? ""; | |
| 121 | if (trimmed.length === 0) { | |
| 122 | return null; | |
| 123 | } | |
| 124 | ||
| 125 | const match = | |
| 126 | /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( | |
| 127 | trimmed, | |
| 128 | ); | |
| 129 | const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; | |
| 130 | return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; | |
| 131 | } | |
| 132 | ||
| 83 | 133 | function deriveLocalBranchNameCandidatesFromRemoteRef( |
| 84 | 134 | branchName: string, |
| 85 | 135 | remoteName?: string, |
scripts/release-smoke.ts+1| ... | ... | @@ -13,6 +13,7 @@ const workspaceFiles = [ |
| 13 | 13 | "apps/desktop/package.json", |
| 14 | 14 | "apps/web/package.json", |
| 15 | 15 | "apps/marketing/package.json", |
| 16 | "packages/client-runtime/package.json", | |
| 16 | 17 | "packages/contracts/package.json", |
| 17 | 18 | "packages/shared/package.json", |
| 18 | 19 | "scripts/package.json", |